use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant};

use anyhow::{anyhow, bail, Context, Result};
use clap::Args;
use serde::Deserialize;
use std::net::Ipv4Addr;
use tonic::transport::{Channel, Endpoint};
use tracing::{info, warn};

use prismnet_api::ipam_service_client::IpamServiceClient;
use prismnet_api::port_service_client::PortServiceClient;
use prismnet_api::router_service_client::RouterServiceClient;
use prismnet_api::security_group_service_client::SecurityGroupServiceClient;
use prismnet_api::subnet_service_client::SubnetServiceClient;
use prismnet_api::vpc_service_client::VpcServiceClient;
use prismnet_api::{
    AddRuleRequest, CreatePortRequest, CreateSecurityGroupRequest, CreateServiceIpPoolRequest,
    CreateRouterRequest, CreateSubnetRequest, CreateVpcRequest, DeletePortRequest,
    DeleteRouterRequest, DeleteSecurityGroupRequest, DeleteServiceIpPoolRequest,
    DeleteSubnetRequest, DeleteVpcRequest, IpProtocol, ListPortsRequest, ListRoutersRequest,
    ListSecurityGroupsRequest, ListServiceIpPoolsRequest, ListSubnetsRequest, ListVpcsRequest,
    Port, RemoveRuleRequest, Router, RuleDirection, SecurityGroup, SecurityGroupRule,
    ServiceIpPool, ServiceIpPoolType, Subnet, UpdatePortRequest, UpdateRouterRequest,
    UpdateSecurityGroupRequest, UpdateServiceIpPoolRequest, UpdateSubnetRequest,
    UpdateVpcRequest, Vpc,
};

use crate::auth::{authorized_request, issue_controller_token};

const SERVICE_READY_TIMEOUT: Duration = Duration::from_secs(180);
const SERVICE_RETRY_INTERVAL: Duration = Duration::from_secs(2);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);

#[derive(Args, Debug)]
pub struct TenantNetworkCommand {
    #[arg(long)]
    config: PathBuf,

    #[arg(long)]
    endpoint: String,

    #[arg(long)]
    iam_endpoint: String,

    #[arg(long)]
    controller_principal_id: String,

    #[arg(long, default_value_t = false)]
    prune: bool,
}

#[derive(Debug, Deserialize)]
struct TenantNetworkConfig {
    #[serde(default)]
    tenants: Vec<TenantSpec>,
}

#[derive(Debug, Deserialize)]
struct TenantSpec {
    org_id: String,
    project_id: String,
    #[serde(default)]
    security_groups: Vec<SecurityGroupSpec>,
    #[serde(default)]
    service_ip_pools: Vec<ServiceIpPoolSpec>,
    #[serde(default)]
    vpcs: Vec<VpcSpec>,
}

#[derive(Debug, Deserialize)]
struct ServiceIpPoolSpec {
    name: String,
    cidr_block: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    pool_type: Option<String>,
}

#[derive(Debug, Deserialize)]
struct SecurityGroupSpec {
    name: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    rules: Vec<SecurityGroupRuleSpec>,
}

#[derive(Debug, Deserialize)]
struct SecurityGroupRuleSpec {
    direction: String,
    #[serde(default)]
    protocol: Option<String>,
    #[serde(default)]
    port_range_min: Option<u32>,
    #[serde(default)]
    port_range_max: Option<u32>,
    #[serde(default)]
    remote_cidr: Option<String>,
    #[serde(default)]
    remote_group: Option<String>,
    #[serde(default)]
    description: Option<String>,
}

#[derive(Debug, Deserialize)]
struct VpcSpec {
    name: String,
    cidr_block: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    router: Option<RouterSpec>,
    #[serde(default)]
    subnets: Vec<SubnetSpec>,
}

#[derive(Debug, Deserialize)]
struct RouterSpec {
    name: String,
    gateway_cidr: String,
    mac_address: String,
    external_ip: String,
    #[serde(default)]
    description: Option<String>,
}

#[derive(Debug, Deserialize)]
struct SubnetSpec {
    name: String,
    cidr_block: String,
    #[serde(default)]
    gateway_ip: Option<String>,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    dhcp_enabled: Option<bool>,
    #[serde(default)]
    ports: Vec<PortSpec>,
}

#[derive(Debug, Deserialize)]
struct PortSpec {
    name: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    ip_address: Option<String>,
    #[serde(default)]
    security_groups: Vec<String>,
    #[serde(default)]
    admin_state_up: Option<bool>,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct RuleFingerprint {
    direction: i32,
    protocol: i32,
    port_range_min: u32,
    port_range_max: u32,
    remote_cidr: String,
    remote_group_id: String,
    description: String,
}

pub async fn run(command: TenantNetworkCommand) -> Result<()> {
    let config = read_json(&command.config).await?;
    validate_config(&config)?;
    reconcile(
        config,
        command.endpoint,
        command.iam_endpoint,
        command.controller_principal_id,
        command.prune,
    )
    .await
}

async fn read_json<T: for<'a> Deserialize<'a>>(path: &PathBuf) -> Result<T> {
    let contents = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("failed to read {}", path.display()))?;
    let config = serde_json::from_str(&contents)
        .with_context(|| format!("failed to parse {}", path.display()))?;
    Ok(config)
}

async fn reconcile(
    config: TenantNetworkConfig,
    endpoint: String,
    iam_endpoint: String,
    controller_principal_id: String,
    prune: bool,
) -> Result<()> {
    let prismnet_channel = connect_with_retry("PrismNET", &endpoint).await?;
    let mut vpc_client = VpcServiceClient::new(prismnet_channel.clone());
    let mut subnet_client = SubnetServiceClient::new(prismnet_channel.clone());
    let mut port_client = PortServiceClient::new(prismnet_channel.clone());
    let mut router_client = RouterServiceClient::new(prismnet_channel.clone());
    let mut sg_client = SecurityGroupServiceClient::new(prismnet_channel.clone());
    let mut ipam_client = IpamServiceClient::new(prismnet_channel);

    for tenant in &config.tenants {
        let token = issue_controller_token_with_retry(
            &iam_endpoint,
            &controller_principal_id,
            &tenant.org_id,
            &tenant.project_id,
        )
        .await?;

        info!(
            org_id = %tenant.org_id,
            project_id = %tenant.project_id,
            prune,
            "reconciling tenant network declarations"
        );

        reconcile_tenant(
            &mut vpc_client,
            &mut subnet_client,
            &mut port_client,
            &mut router_client,
            &mut sg_client,
            &mut ipam_client,
            tenant,
            &token,
            prune,
        )
        .await?;
    }

    Ok(())
}

async fn connect_with_retry(service_name: &str, endpoint: &str) -> Result<Channel> {
    let deadline = Instant::now() + SERVICE_READY_TIMEOUT;

    loop {
        match Endpoint::from_shared(endpoint.to_string())
            .context("invalid gRPC endpoint")?
            .connect_timeout(CONNECT_TIMEOUT)
            .connect()
            .await
        {
            Ok(channel) => return Ok(channel),
            Err(error) => {
                if Instant::now() >= deadline {
                    return Err(anyhow!(error).context(format!(
                        "{service_name} at {endpoint} did not become ready within {} seconds",
                        SERVICE_READY_TIMEOUT.as_secs()
                    )));
                }
                warn!(
                    service = service_name,
                    endpoint,
                    error = %error,
                    "service is not ready yet; retrying"
                );
                tokio::time::sleep(SERVICE_RETRY_INTERVAL).await;
            }
        }
    }
}

async fn issue_controller_token_with_retry(
    iam_endpoint: &str,
    controller_principal_id: &str,
    org_id: &str,
    project_id: &str,
) -> Result<String> {
    let deadline = Instant::now() + SERVICE_READY_TIMEOUT;

    loop {
        match issue_controller_token(iam_endpoint, controller_principal_id, org_id, project_id).await
        {
            Ok(token) => return Ok(token),
            Err(error) => {
                if Instant::now() >= deadline {
                    return Err(error.context(format!(
                        "failed to issue controller token for tenant {org_id}/{project_id} within {} seconds",
                        SERVICE_READY_TIMEOUT.as_secs()
                    )));
                }
                warn!(
                    iam_endpoint,
                    org_id,
                    project_id,
                    error = %error,
                    "IAM is not ready to issue controller tokens yet; retrying"
                );
                tokio::time::sleep(SERVICE_RETRY_INTERVAL).await;
            }
        }
    }
}

async fn reconcile_tenant(
    vpc_client: &mut VpcServiceClient<Channel>,
    subnet_client: &mut SubnetServiceClient<Channel>,
    port_client: &mut PortServiceClient<Channel>,
    router_client: &mut RouterServiceClient<Channel>,
    sg_client: &mut SecurityGroupServiceClient<Channel>,
    ipam_client: &mut IpamServiceClient<Channel>,
    tenant: &TenantSpec,
    token: &str,
    prune: bool,
) -> Result<()> {
    let mut security_groups = list_security_groups(sg_client, tenant, token).await?;
    let mut security_group_ids = HashMap::new();
    for spec in &tenant.security_groups {
        let sg = ensure_security_group(sg_client, tenant, spec, &security_groups, token).await?;
        security_group_ids.insert(spec.name.clone(), sg.id.clone());
    }

    security_groups = list_security_groups(sg_client, tenant, token).await?;
    for spec in &tenant.security_groups {
        let actual = security_groups
            .iter()
            .find(|sg| sg.name == spec.name)
            .with_context(|| format!("security group {} not found after reconciliation", spec.name))?;
        sync_security_group_rules(sg_client, tenant, spec, actual, &security_group_ids, token, prune)
            .await?;
    }

    let service_ip_pools = list_service_ip_pools(ipam_client, tenant, token).await?;
    let mut desired_service_ip_pool_names = HashSet::new();
    for pool_spec in &tenant.service_ip_pools {
        desired_service_ip_pool_names.insert(pool_spec.name.clone());
        ensure_service_ip_pool(ipam_client, tenant, pool_spec, &service_ip_pools, token).await?;
    }

    if prune {
        let current_pools = list_service_ip_pools(ipam_client, tenant, token).await?;
        for pool in current_pools {
            if !desired_service_ip_pool_names.contains(&pool.name) {
                info!(
                    org_id = %tenant.org_id,
                    project_id = %tenant.project_id,
                    pool = %pool.name,
                    "deleting unmanaged service ip pool"
                );
                ipam_client
                    .delete_service_ip_pool(authorized_request(
                        DeleteServiceIpPoolRequest {
                            org_id: tenant.org_id.clone(),
                            project_id: tenant.project_id.clone(),
                            id: pool.id,
                        },
                        token,
                    ))
                    .await?;
            }
        }
    }

    let vpcs = list_vpcs(vpc_client, tenant, token).await?;
    let mut desired_vpc_names = HashSet::new();
    for vpc_spec in &tenant.vpcs {
        desired_vpc_names.insert(vpc_spec.name.clone());
        let vpc = ensure_vpc(vpc_client, tenant, vpc_spec, &vpcs, token).await?;
        reconcile_vpc(
            subnet_client,
            port_client,
            router_client,
            tenant,
            vpc_spec,
            &vpc,
            &security_group_ids,
            token,
            prune,
        )
        .await?;
    }

    if prune {
        let current_vpcs = list_vpcs(vpc_client, tenant, token).await?;
        for vpc in current_vpcs {
            if !desired_vpc_names.contains(&vpc.name) {
                delete_vpc_tree(
                    vpc_client,
                    subnet_client,
                    port_client,
                    router_client,
                    tenant,
                    &vpc,
                    token,
                )
                .await?;
            }
        }

        let current_security_groups = list_security_groups(sg_client, tenant, token).await?;
        let desired_security_groups: HashSet<_> = tenant
            .security_groups
            .iter()
            .map(|sg| sg.name.as_str())
            .collect();
        for sg in current_security_groups {
            if !desired_security_groups.contains(sg.name.as_str()) {
                info!(
                    org_id = %tenant.org_id,
                    project_id = %tenant.project_id,
                    security_group = %sg.name,
                    "deleting unmanaged security group"
                );
                sg_client
                    .delete_security_group(authorized_request(
                        DeleteSecurityGroupRequest {
                            org_id: tenant.org_id.clone(),
                            project_id: tenant.project_id.clone(),
                            id: sg.id,
                        },
                        token,
                    ))
                    .await?;
            }
        }
    }

    Ok(())
}

async fn reconcile_vpc(
    subnet_client: &mut SubnetServiceClient<Channel>,
    port_client: &mut PortServiceClient<Channel>,
    router_client: &mut RouterServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc_spec: &VpcSpec,
    vpc: &Vpc,
    security_group_ids: &HashMap<String, String>,
    token: &str,
    prune: bool,
) -> Result<()> {
    let routers = list_routers(router_client, tenant, Some(&vpc.id), token).await?;
    if let Some(router_spec) = vpc_spec.router.as_ref() {
        let router = ensure_router(router_client, tenant, vpc, router_spec, &routers, token).await?;
        if prune {
            for existing in routers {
                if existing.id != router.id {
                    info!(
                        org_id = %tenant.org_id,
                        project_id = %tenant.project_id,
                        vpc = %vpc.name,
                        router = %existing.name,
                        "deleting unmanaged router"
                    );
                    router_client
                        .delete_router(authorized_request(
                            DeleteRouterRequest {
                                org_id: tenant.org_id.clone(),
                                project_id: tenant.project_id.clone(),
                                id: existing.id,
                            },
                            token,
                        ))
                        .await?;
                }
            }
        }
    } else if prune {
        for router in routers {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                vpc = %vpc.name,
                router = %router.name,
                "deleting unmanaged router"
            );
            router_client
                .delete_router(authorized_request(
                    DeleteRouterRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        id: router.id,
                    },
                    token,
                ))
                .await?;
        }
    }

    let subnets = list_subnets(subnet_client, tenant, &vpc.id, token).await?;
    let mut desired_subnet_names = HashSet::new();
    for subnet_spec in &vpc_spec.subnets {
        desired_subnet_names.insert(subnet_spec.name.clone());
        let subnet = ensure_subnet(subnet_client, tenant, vpc, subnet_spec, &subnets, token).await?;
        reconcile_subnet(
            port_client,
            tenant,
            subnet_spec,
            &subnet,
            security_group_ids,
            token,
            prune,
        )
        .await?;
    }

    if prune {
        let current_subnets = list_subnets(subnet_client, tenant, &vpc.id, token).await?;
        for subnet in current_subnets {
            if !desired_subnet_names.contains(&subnet.name) {
                delete_subnet_tree(subnet_client, port_client, tenant, vpc, &subnet, token).await?;
            }
        }
    }

    Ok(())
}

async fn reconcile_subnet(
    port_client: &mut PortServiceClient<Channel>,
    tenant: &TenantSpec,
    subnet_spec: &SubnetSpec,
    subnet: &Subnet,
    security_group_ids: &HashMap<String, String>,
    token: &str,
    prune: bool,
) -> Result<()> {
    let ports = list_ports(port_client, tenant, &subnet.id, token).await?;
    let mut desired_port_names = HashSet::new();
    for port_spec in &subnet_spec.ports {
        desired_port_names.insert(port_spec.name.clone());
        ensure_port(
            port_client,
            tenant,
            subnet,
            port_spec,
            &ports,
            security_group_ids,
            token,
        )
        .await?;
    }

    if prune {
        let current_ports = list_ports(port_client, tenant, &subnet.id, token).await?;
        for port in current_ports {
            if !desired_port_names.contains(&port.name) {
                info!(
                    org_id = %tenant.org_id,
                    project_id = %tenant.project_id,
                    subnet = %subnet.name,
                    port = %port.name,
                    "deleting unmanaged port"
                );
                port_client
                    .delete_port(authorized_request(
                        DeletePortRequest {
                            org_id: tenant.org_id.clone(),
                            project_id: tenant.project_id.clone(),
                            subnet_id: subnet.id.clone(),
                            id: port.id,
                        },
                        token,
                    ))
                    .await?;
            }
        }
    }

    Ok(())
}

async fn ensure_security_group(
    sg_client: &mut SecurityGroupServiceClient<Channel>,
    tenant: &TenantSpec,
    spec: &SecurityGroupSpec,
    existing: &[SecurityGroup],
    token: &str,
) -> Result<SecurityGroup> {
    if let Some(sg) = existing.iter().find(|sg| sg.name == spec.name) {
        let desired_description = string_or_default(spec.description.as_deref());
        if sg.description != desired_description {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                security_group = %spec.name,
                "updating security group"
            );
            let response = sg_client
                .update_security_group(authorized_request(
                    UpdateSecurityGroupRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        id: sg.id.clone(),
                        name: spec.name.clone(),
                        description: desired_description,
                    },
                    token,
                ))
                .await?
                .into_inner();
            return response
                .security_group
                .context("missing security group in update response");
        }

        return Ok(sg.clone());
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        security_group = %spec.name,
        "creating security group"
    );
    let response = sg_client
        .create_security_group(authorized_request(
            CreateSecurityGroupRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                name: spec.name.clone(),
                description: string_or_default(spec.description.as_deref()),
            },
            token,
        ))
        .await?
        .into_inner();

    response
        .security_group
        .context("missing security group in create response")
}

async fn sync_security_group_rules(
    sg_client: &mut SecurityGroupServiceClient<Channel>,
    tenant: &TenantSpec,
    spec: &SecurityGroupSpec,
    actual: &SecurityGroup,
    security_group_ids: &HashMap<String, String>,
    token: &str,
    prune: bool,
) -> Result<()> {
    let actual_rules: HashMap<RuleFingerprint, String> = actual
        .rules
        .iter()
        .map(|rule| (fingerprint_actual_rule(rule), rule.id.clone()))
        .collect();

    let mut desired_rules = HashSet::new();
    for desired_rule in &spec.rules {
        let fingerprint = fingerprint_desired_rule(desired_rule, security_group_ids)?;
        let is_new = !actual_rules.contains_key(&fingerprint);
        desired_rules.insert(fingerprint.clone());

        if is_new {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                security_group = %spec.name,
                "adding security group rule"
            );
            sg_client
                .add_rule(authorized_request(
                    AddRuleRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        security_group_id: actual.id.clone(),
                        direction: fingerprint.direction,
                        protocol: fingerprint.protocol,
                        port_range_min: fingerprint.port_range_min,
                        port_range_max: fingerprint.port_range_max,
                        remote_cidr: fingerprint.remote_cidr.clone(),
                        remote_group_id: fingerprint.remote_group_id.clone(),
                        description: fingerprint.description.clone(),
                    },
                    token,
                ))
                .await?;
        }
    }

    if prune {
        for (fingerprint, rule_id) in actual_rules {
            if !desired_rules.contains(&fingerprint) {
                info!(
                    org_id = %tenant.org_id,
                    project_id = %tenant.project_id,
                    security_group = %spec.name,
                    "removing unmanaged security group rule"
                );
                sg_client
                    .remove_rule(authorized_request(
                        RemoveRuleRequest {
                            org_id: tenant.org_id.clone(),
                            project_id: tenant.project_id.clone(),
                            security_group_id: actual.id.clone(),
                            rule_id,
                        },
                        token,
                    ))
                    .await?;
            }
        }
    }

    Ok(())
}

async fn ensure_vpc(
    vpc_client: &mut VpcServiceClient<Channel>,
    tenant: &TenantSpec,
    spec: &VpcSpec,
    existing: &[Vpc],
    token: &str,
) -> Result<Vpc> {
    if let Some(vpc) = existing.iter().find(|vpc| vpc.name == spec.name) {
        ensure_field_matches(
            "vpc",
            &spec.name,
            "cidr_block",
            &vpc.cidr_block,
            &spec.cidr_block,
        )?;

        let desired_description = string_or_default(spec.description.as_deref());
        if vpc.description != desired_description {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                vpc = %spec.name,
                "updating vpc"
            );
            let response = vpc_client
                .update_vpc(authorized_request(
                    UpdateVpcRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        id: vpc.id.clone(),
                        name: spec.name.clone(),
                        description: desired_description,
                    },
                    token,
                ))
                .await?
                .into_inner();
            return response.vpc.context("missing vpc in update response");
        }

        return Ok(vpc.clone());
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        vpc = %spec.name,
        "creating vpc"
    );
    let response = vpc_client
        .create_vpc(authorized_request(
            CreateVpcRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                name: spec.name.clone(),
                description: string_or_default(spec.description.as_deref()),
                cidr_block: spec.cidr_block.clone(),
            },
            token,
        ))
        .await?
        .into_inner();

    response.vpc.context("missing vpc in create response")
}

async fn ensure_router(
    router_client: &mut RouterServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc: &Vpc,
    spec: &RouterSpec,
    existing: &[Router],
    token: &str,
) -> Result<Router> {
    if let Some(router) = existing.iter().find(|router| router.name == spec.name) {
        ensure_field_matches(
            "router",
            &spec.name,
            "gateway_cidr",
            &router.gateway_cidr,
            &spec.gateway_cidr,
        )?;
        ensure_field_matches(
            "router",
            &spec.name,
            "mac_address",
            &router.mac_address,
            &spec.mac_address,
        )?;
        ensure_field_matches(
            "router",
            &spec.name,
            "external_ip",
            &router.external_ip,
            &spec.external_ip,
        )?;

        let desired_description = string_or_default(spec.description.as_deref());
        if router.description != desired_description {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                vpc = %vpc.name,
                router = %spec.name,
                "updating router"
            );
            let response = router_client
                .update_router(authorized_request(
                    UpdateRouterRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        id: router.id.clone(),
                        name: spec.name.clone(),
                        description: desired_description,
                    },
                    token,
                ))
                .await?
                .into_inner();
            return response.router.context("missing router in update response");
        }

        return Ok(router.clone());
    }

    if existing.len() == 1 {
        let router = &existing[0];
        ensure_field_matches(
            "router",
            &router.name,
            "gateway_cidr",
            &router.gateway_cidr,
            &spec.gateway_cidr,
        )?;
        ensure_field_matches(
            "router",
            &router.name,
            "mac_address",
            &router.mac_address,
            &spec.mac_address,
        )?;
        ensure_field_matches(
            "router",
            &router.name,
            "external_ip",
            &router.external_ip,
            &spec.external_ip,
        )?;

        info!(
            org_id = %tenant.org_id,
            project_id = %tenant.project_id,
            vpc = %vpc.name,
            router = %router.name,
            "renaming router to match declaration"
        );
        let response = router_client
            .update_router(authorized_request(
                UpdateRouterRequest {
                    org_id: tenant.org_id.clone(),
                    project_id: tenant.project_id.clone(),
                    id: router.id.clone(),
                    name: spec.name.clone(),
                    description: string_or_default(spec.description.as_deref()),
                },
                token,
            ))
            .await?
            .into_inner();
        return response.router.context("missing router in update response");
    }

    if existing.len() > 1 {
        bail!(
            "vpc {} has multiple routers; reconcile cannot determine which one to keep",
            vpc.name
        );
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        vpc = %vpc.name,
        router = %spec.name,
        "creating router"
    );
    let response = router_client
        .create_router(authorized_request(
            CreateRouterRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                vpc_id: vpc.id.clone(),
                name: spec.name.clone(),
                description: string_or_default(spec.description.as_deref()),
                gateway_cidr: spec.gateway_cidr.clone(),
                mac_address: spec.mac_address.clone(),
                external_ip: spec.external_ip.clone(),
            },
            token,
        ))
        .await?
        .into_inner();

    response.router.context("missing router in create response")
}

async fn ensure_subnet(
    subnet_client: &mut SubnetServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc: &Vpc,
    spec: &SubnetSpec,
    existing: &[Subnet],
    token: &str,
) -> Result<Subnet> {
    if let Some(subnet) = existing.iter().find(|subnet| subnet.name == spec.name) {
        ensure_field_matches(
            "subnet",
            &spec.name,
            "cidr_block",
            &subnet.cidr_block,
            &spec.cidr_block,
        )?;

        if let Some(gateway_ip) = spec.gateway_ip.as_deref() {
            ensure_field_matches("subnet", &spec.name, "gateway_ip", &subnet.gateway_ip, gateway_ip)?;
        }

        let desired_description = string_or_default(spec.description.as_deref());
        let desired_dhcp_enabled = spec.dhcp_enabled.unwrap_or(true);
        if subnet.description != desired_description || subnet.dhcp_enabled != desired_dhcp_enabled {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                vpc = %vpc.name,
                subnet = %spec.name,
                "updating subnet"
            );
            let response = subnet_client
                .update_subnet(authorized_request(
                    UpdateSubnetRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        vpc_id: vpc.id.clone(),
                        id: subnet.id.clone(),
                        name: spec.name.clone(),
                        description: desired_description,
                        dhcp_enabled: desired_dhcp_enabled,
                    },
                    token,
                ))
                .await?
                .into_inner();
            return response.subnet.context("missing subnet in update response");
        }

        return Ok(subnet.clone());
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        vpc = %vpc.name,
        subnet = %spec.name,
        "creating subnet"
    );
    let response = subnet_client
        .create_subnet(authorized_request(
            CreateSubnetRequest {
                vpc_id: vpc.id.clone(),
                name: spec.name.clone(),
                description: string_or_default(spec.description.as_deref()),
                cidr_block: spec.cidr_block.clone(),
                gateway_ip: string_or_default(spec.gateway_ip.as_deref()),
                dhcp_enabled: spec.dhcp_enabled.unwrap_or(true),
            },
            token,
        ))
        .await?
        .into_inner();

    response.subnet.context("missing subnet in create response")
}

async fn ensure_port(
    port_client: &mut PortServiceClient<Channel>,
    tenant: &TenantSpec,
    subnet: &Subnet,
    spec: &PortSpec,
    existing: &[Port],
    security_group_ids: &HashMap<String, String>,
    token: &str,
) -> Result<Port> {
    let desired_security_group_ids = resolve_security_group_ids(&spec.security_groups, security_group_ids)?;
    if let Some(port) = existing.iter().find(|port| port.name == spec.name) {
        if let Some(ip_address) = spec.ip_address.as_deref() {
            ensure_field_matches("port", &spec.name, "ip_address", &port.ip_address, ip_address)?;
        }

        let desired_description = string_or_default(spec.description.as_deref());
        let desired_admin_state_up = spec.admin_state_up.unwrap_or(true);
        let actual_security_group_ids: HashSet<_> =
            port.security_group_ids.iter().cloned().collect();
        let desired_security_group_ids_set: HashSet<_> =
            desired_security_group_ids.iter().cloned().collect();
        let needs_update = port.description != desired_description
            || port.admin_state_up != desired_admin_state_up
            || actual_security_group_ids != desired_security_group_ids_set;

        if needs_update {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                subnet = %subnet.name,
                port = %spec.name,
                "updating port"
            );
            let response = port_client
                .update_port(authorized_request(
                    UpdatePortRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        subnet_id: subnet.id.clone(),
                        id: port.id.clone(),
                        name: spec.name.clone(),
                        description: desired_description,
                        security_group_ids: desired_security_group_ids,
                        admin_state_up: desired_admin_state_up,
                    },
                    token,
                ))
                .await?
                .into_inner();
            return response.port.context("missing port in update response");
        }

        return Ok(port.clone());
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        subnet = %subnet.name,
        port = %spec.name,
        "creating port"
    );
    let mut port = port_client
        .create_port(authorized_request(
            CreatePortRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                subnet_id: subnet.id.clone(),
                name: spec.name.clone(),
                description: string_or_default(spec.description.as_deref()),
                ip_address: string_or_default(spec.ip_address.as_deref()),
                security_group_ids: desired_security_group_ids.clone(),
            },
            token,
        ))
        .await?
        .into_inner()
        .port
        .context("missing port in create response")?;

    if !spec.admin_state_up.unwrap_or(true) {
        port = port_client
            .update_port(authorized_request(
                UpdatePortRequest {
                    org_id: tenant.org_id.clone(),
                    project_id: tenant.project_id.clone(),
                    subnet_id: subnet.id.clone(),
                    id: port.id.clone(),
                    name: String::new(),
                    description: String::new(),
                    security_group_ids: Vec::new(),
                    admin_state_up: false,
                },
                token,
            ))
            .await?
            .into_inner()
            .port
            .context("missing port in update response")?;
    }

    Ok(port)
}

async fn delete_vpc_tree(
    vpc_client: &mut VpcServiceClient<Channel>,
    subnet_client: &mut SubnetServiceClient<Channel>,
    port_client: &mut PortServiceClient<Channel>,
    router_client: &mut RouterServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc: &Vpc,
    token: &str,
) -> Result<()> {
    let routers = list_routers(router_client, tenant, Some(&vpc.id), token).await?;
    for router in routers {
        info!(
            org_id = %tenant.org_id,
            project_id = %tenant.project_id,
            vpc = %vpc.name,
            router = %router.name,
            "deleting unmanaged router"
        );
        router_client
            .delete_router(authorized_request(
                DeleteRouterRequest {
                    org_id: tenant.org_id.clone(),
                    project_id: tenant.project_id.clone(),
                    id: router.id,
                },
                token,
            ))
            .await?;
    }

    let subnets = list_subnets(subnet_client, tenant, &vpc.id, token).await?;
    for subnet in subnets {
        delete_subnet_tree(subnet_client, port_client, tenant, vpc, &subnet, token).await?;
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        vpc = %vpc.name,
        "deleting unmanaged vpc"
    );
    vpc_client
        .delete_vpc(authorized_request(
            DeleteVpcRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                id: vpc.id.clone(),
            },
            token,
        ))
        .await?;

    Ok(())
}

async fn delete_subnet_tree(
    subnet_client: &mut SubnetServiceClient<Channel>,
    port_client: &mut PortServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc: &Vpc,
    subnet: &Subnet,
    token: &str,
) -> Result<()> {
    let ports = list_ports(port_client, tenant, &subnet.id, token).await?;
    for port in ports {
        info!(
            org_id = %tenant.org_id,
            project_id = %tenant.project_id,
            subnet = %subnet.name,
            port = %port.name,
            "deleting unmanaged port"
        );
        port_client
            .delete_port(authorized_request(
                DeletePortRequest {
                    org_id: tenant.org_id.clone(),
                    project_id: tenant.project_id.clone(),
                    subnet_id: subnet.id.clone(),
                    id: port.id,
                },
                token,
            ))
            .await?;
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        vpc = %vpc.name,
        subnet = %subnet.name,
        "deleting unmanaged subnet"
    );
    subnet_client
        .delete_subnet(authorized_request(
            DeleteSubnetRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                vpc_id: vpc.id.clone(),
                id: subnet.id.clone(),
            },
            token,
        ))
        .await?;

    Ok(())
}

async fn ensure_service_ip_pool(
    ipam_client: &mut IpamServiceClient<Channel>,
    tenant: &TenantSpec,
    spec: &ServiceIpPoolSpec,
    existing: &[ServiceIpPool],
    token: &str,
) -> Result<ServiceIpPool> {
    let desired_pool_type = parse_service_ip_pool_type(spec.pool_type.as_deref())?;
    if let Some(pool) = existing.iter().find(|pool| pool.name == spec.name) {
        ensure_field_matches(
            "service ip pool",
            &spec.name,
            "cidr_block",
            &pool.cidr_block,
            &spec.cidr_block,
        )?;
        ensure_field_matches(
            "service ip pool",
            &spec.name,
            "pool_type",
            service_ip_pool_type_name(pool.pool_type),
            service_ip_pool_type_name(desired_pool_type),
        )?;

        let desired_description = string_or_default(spec.description.as_deref());
        if pool.description != desired_description {
            info!(
                org_id = %tenant.org_id,
                project_id = %tenant.project_id,
                pool = %spec.name,
                "updating service ip pool"
            );
            let response = ipam_client
                .update_service_ip_pool(authorized_request(
                    UpdateServiceIpPoolRequest {
                        org_id: tenant.org_id.clone(),
                        project_id: tenant.project_id.clone(),
                        id: pool.id.clone(),
                        name: spec.name.clone(),
                        description: desired_description,
                    },
                    token,
                ))
                .await?
                .into_inner();
            return response.pool.context("missing service ip pool in update response");
        }

        return Ok(pool.clone());
    }

    info!(
        org_id = %tenant.org_id,
        project_id = %tenant.project_id,
        pool = %spec.name,
        "creating service ip pool"
    );
    let response = ipam_client
        .create_service_ip_pool(authorized_request(
            CreateServiceIpPoolRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                name: spec.name.clone(),
                description: string_or_default(spec.description.as_deref()),
                cidr_block: spec.cidr_block.clone(),
                pool_type: desired_pool_type,
            },
            token,
        ))
        .await?
        .into_inner();

    response
        .pool
        .context("missing service ip pool in create response")
}

async fn list_vpcs(
    vpc_client: &mut VpcServiceClient<Channel>,
    tenant: &TenantSpec,
    token: &str,
) -> Result<Vec<Vpc>> {
    let response = vpc_client
        .list_vpcs(authorized_request(
            ListVpcsRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                page_size: 500,
                page_token: String::new(),
            },
            token,
        ))
        .await?
        .into_inner();
    Ok(response.vpcs)
}

async fn list_routers(
    router_client: &mut RouterServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc_id: Option<&str>,
    token: &str,
) -> Result<Vec<Router>> {
    let response = router_client
        .list_routers(authorized_request(
            ListRoutersRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                vpc_id: vpc_id.unwrap_or_default().to_string(),
                page_size: 500,
                page_token: String::new(),
            },
            token,
        ))
        .await?
        .into_inner();
    Ok(response.routers)
}

async fn list_subnets(
    subnet_client: &mut SubnetServiceClient<Channel>,
    tenant: &TenantSpec,
    vpc_id: &str,
    token: &str,
) -> Result<Vec<Subnet>> {
    let response = subnet_client
        .list_subnets(authorized_request(
            ListSubnetsRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                vpc_id: vpc_id.to_string(),
                page_size: 500,
                page_token: String::new(),
            },
            token,
        ))
        .await?
        .into_inner();
    Ok(response.subnets)
}

async fn list_ports(
    port_client: &mut PortServiceClient<Channel>,
    tenant: &TenantSpec,
    subnet_id: &str,
    token: &str,
) -> Result<Vec<Port>> {
    let response = port_client
        .list_ports(authorized_request(
            ListPortsRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                subnet_id: subnet_id.to_string(),
                device_id: String::new(),
                page_size: 500,
                page_token: String::new(),
            },
            token,
        ))
        .await?
        .into_inner();
    Ok(response.ports)
}

async fn list_security_groups(
    sg_client: &mut SecurityGroupServiceClient<Channel>,
    tenant: &TenantSpec,
    token: &str,
) -> Result<Vec<SecurityGroup>> {
    let response = sg_client
        .list_security_groups(authorized_request(
            ListSecurityGroupsRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                page_size: 500,
                page_token: String::new(),
            },
            token,
        ))
        .await?
        .into_inner();
    Ok(response.security_groups)
}

async fn list_service_ip_pools(
    ipam_client: &mut IpamServiceClient<Channel>,
    tenant: &TenantSpec,
    token: &str,
) -> Result<Vec<ServiceIpPool>> {
    let response = ipam_client
        .list_service_ip_pools(authorized_request(
            ListServiceIpPoolsRequest {
                org_id: tenant.org_id.clone(),
                project_id: tenant.project_id.clone(),
                pool_type: 0,
                page_size: 500,
                page_token: String::new(),
            },
            token,
        ))
        .await?
        .into_inner();
    Ok(response.pools)
}

fn resolve_security_group_ids(
    names: &[String],
    security_group_ids: &HashMap<String, String>,
) -> Result<Vec<String>> {
    let mut resolved = Vec::with_capacity(names.len());
    for name in names {
        let id = security_group_ids
            .get(name)
            .with_context(|| format!("unknown security group reference {}", name))?;
        resolved.push(id.clone());
    }
    resolved.sort();
    resolved.dedup();
    Ok(resolved)
}

fn fingerprint_actual_rule(rule: &SecurityGroupRule) -> RuleFingerprint {
    RuleFingerprint {
        direction: rule.direction,
        protocol: rule.protocol,
        port_range_min: rule.port_range_min,
        port_range_max: rule.port_range_max,
        remote_cidr: rule.remote_cidr.clone(),
        remote_group_id: rule.remote_group_id.clone(),
        description: rule.description.clone(),
    }
}

fn fingerprint_desired_rule(
    rule: &SecurityGroupRuleSpec,
    security_group_ids: &HashMap<String, String>,
) -> Result<RuleFingerprint> {
    let remote_group_id = match rule.remote_group.as_deref() {
        Some(name) => security_group_ids
            .get(name)
            .with_context(|| format!("unknown remote security group {}", name))?
            .clone(),
        None => String::new(),
    };

    Ok(RuleFingerprint {
        direction: parse_direction(&rule.direction)?,
        protocol: parse_protocol(rule.protocol.as_deref())?,
        port_range_min: rule.port_range_min.unwrap_or(0),
        port_range_max: rule.port_range_max.unwrap_or(0),
        remote_cidr: string_or_default(rule.remote_cidr.as_deref()),
        remote_group_id,
        description: string_or_default(rule.description.as_deref()),
    })
}

fn parse_direction(direction: &str) -> Result<i32> {
    match direction.to_ascii_lowercase().as_str() {
        "ingress" => Ok(RuleDirection::Ingress as i32),
        "egress" => Ok(RuleDirection::Egress as i32),
        other => bail!("unsupported rule direction {}", other),
    }
}

fn parse_protocol(protocol: Option<&str>) -> Result<i32> {
    match protocol
        .unwrap_or("any")
        .to_ascii_lowercase()
        .as_str()
    {
        "any" => Ok(IpProtocol::Any as i32),
        "tcp" => Ok(IpProtocol::Tcp as i32),
        "udp" => Ok(IpProtocol::Udp as i32),
        "icmp" => Ok(IpProtocol::Icmp as i32),
        "icmpv6" => Ok(IpProtocol::Icmpv6 as i32),
        other => bail!("unsupported ip protocol {}", other),
    }
}

fn parse_service_ip_pool_type(pool_type: Option<&str>) -> Result<i32> {
    match pool_type.unwrap_or("cluster_ip").to_ascii_lowercase().as_str() {
        "cluster_ip" => Ok(ServiceIpPoolType::ClusterIp as i32),
        "load_balancer" => Ok(ServiceIpPoolType::LoadBalancer as i32),
        "node_port" => Ok(ServiceIpPoolType::NodePort as i32),
        other => bail!("unsupported service ip pool type {}", other),
    }
}

fn service_ip_pool_type_name(pool_type: i32) -> &'static str {
    match pool_type {
        x if x == ServiceIpPoolType::ClusterIp as i32 => "cluster_ip",
        x if x == ServiceIpPoolType::LoadBalancer as i32 => "load_balancer",
        x if x == ServiceIpPoolType::NodePort as i32 => "node_port",
        _ => "unspecified",
    }
}

fn parse_ipv4_cidr(cidr: &str) -> Result<(Ipv4Addr, u8)> {
    let (ip, prefix) = cidr
        .split_once('/')
        .with_context(|| format!("invalid CIDR {}", cidr))?;
    let ip: Ipv4Addr = ip
        .parse()
        .with_context(|| format!("invalid IPv4 address in CIDR {}", cidr))?;
    let prefix: u8 = prefix
        .parse()
        .with_context(|| format!("invalid prefix in CIDR {}", cidr))?;
    if prefix > 32 {
        bail!("invalid prefix in CIDR {}", cidr);
    }
    Ok((ip, prefix))
}

fn parse_ipv4(ip: &str) -> Result<Ipv4Addr> {
    ip.parse()
        .with_context(|| format!("invalid IPv4 address {}", ip))
}

fn validate_mac_address(mac_address: &str) -> Result<()> {
    let octets: Vec<_> = mac_address.split(':').collect();
    if octets.len() != 6
        || octets
            .iter()
            .any(|octet| octet.len() != 2 || u8::from_str_radix(octet, 16).is_err())
    {
        bail!("invalid mac_address {}", mac_address);
    }
    Ok(())
}

fn cidr_range(cidr: (Ipv4Addr, u8)) -> (u32, u32) {
    let mask = if cidr.1 == 0 {
        0
    } else {
        u32::MAX << (32 - cidr.1)
    };
    let start = u32::from(cidr.0) & mask;
    let size = if cidr.1 == 32 { 1 } else { 1u64 << (32 - cidr.1) };
    let end = start + (size as u32) - 1;
    (start, end)
}

fn cidr_contains(cidr: (Ipv4Addr, u8), ip: Ipv4Addr) -> bool {
    let mask = if cidr.1 == 0 { 0 } else { u32::MAX << (32 - cidr.1) };
    (u32::from(cidr.0) & mask) == (u32::from(ip) & mask)
}

fn cidr_contains_cidr(parent: (Ipv4Addr, u8), child: (Ipv4Addr, u8)) -> bool {
    let (parent_start, parent_end) = cidr_range(parent);
    let (child_start, child_end) = cidr_range(child);
    child_start >= parent_start && child_end <= parent_end
}

fn cidr_overlaps(a: (Ipv4Addr, u8), b: (Ipv4Addr, u8)) -> bool {
    let (a_start, a_end) = cidr_range(a);
    let (b_start, b_end) = cidr_range(b);
    a_start <= b_end && b_start <= a_end
}

fn validate_router_spec(vpc: &VpcSpec, router: &RouterSpec) -> Result<()> {
    let vpc_cidr = parse_ipv4_cidr(&vpc.cidr_block)?;
    let (gateway_ip, _) = parse_ipv4_cidr(&router.gateway_cidr)?;
    let _: Ipv4Addr = router
        .external_ip
        .parse()
        .with_context(|| format!("invalid external_ip {}", router.external_ip))?;
    validate_mac_address(&router.mac_address)?;
    if !cidr_contains(vpc_cidr, gateway_ip) {
        bail!(
            "router {} gateway_cidr must fall inside vpc {} cidr_block",
            router.name,
            vpc.name
        );
    }
    Ok(())
}

fn ensure_field_matches(
    resource_kind: &str,
    resource_name: &str,
    field_name: &str,
    actual: &str,
    desired: &str,
) -> Result<()> {
    if actual != desired {
        bail!(
            "{} {} has immutable field drift: {} is {:?}, expected {:?}",
            resource_kind,
            resource_name,
            field_name,
            actual,
            desired
        );
    }
    Ok(())
}

fn string_or_default(value: Option<&str>) -> String {
    value.unwrap_or_default().to_string()
}

fn validate_config(config: &TenantNetworkConfig) -> Result<()> {
    let mut tenant_scopes = HashSet::new();
    for tenant in &config.tenants {
        if tenant.org_id.trim().is_empty() || tenant.project_id.trim().is_empty() {
            bail!("tenant org_id/project_id must be non-empty");
        }

        if !tenant_scopes.insert((tenant.org_id.clone(), tenant.project_id.clone())) {
            bail!(
                "duplicate tenant scope {}/{} in tenant networking config",
                tenant.org_id,
                tenant.project_id
            );
        }

        validate_unique_names(
            "security group",
            tenant
                .security_groups
                .iter()
                .map(|sg| sg.name.as_str()),
        )?;
        validate_unique_names(
            "service ip pool",
            tenant.service_ip_pools.iter().map(|pool| pool.name.as_str()),
        )?;
        validate_unique_names("vpc", tenant.vpcs.iter().map(|vpc| vpc.name.as_str()))?;

        let security_group_names: HashSet<_> =
            tenant.security_groups.iter().map(|sg| sg.name.as_str()).collect();
        for pool in &tenant.service_ip_pools {
            if pool.cidr_block.trim().is_empty() {
                bail!("service ip pool {} must set cidr_block", pool.name);
            }
            parse_ipv4_cidr(&pool.cidr_block)?;
            parse_service_ip_pool_type(pool.pool_type.as_deref())?;
        }
        for sg in &tenant.security_groups {
            for rule in &sg.rules {
                if rule.remote_cidr.is_some() && rule.remote_group.is_some() {
                    bail!(
                        "security group {} rule cannot set both remote_cidr and remote_group",
                        sg.name
                    );
                }
                if let Some(remote_group) = rule.remote_group.as_deref() {
                    if !security_group_names.contains(remote_group) {
                        bail!(
                            "security group {} references unknown remote_group {}",
                            sg.name,
                            remote_group
                        );
                    }
                }
                if let Some(remote_cidr) = rule.remote_cidr.as_deref() {
                    parse_ipv4_cidr(remote_cidr)?;
                }
                if let (Some(min), Some(max)) = (rule.port_range_min, rule.port_range_max) {
                    if min > max {
                        bail!(
                            "security group {} has invalid port range {}-{}",
                            sg.name,
                            min,
                            max
                        );
                    }
                }
                parse_direction(&rule.direction)?;
                parse_protocol(rule.protocol.as_deref())?;
            }
        }

        for vpc in &tenant.vpcs {
            let vpc_cidr = parse_ipv4_cidr(&vpc.cidr_block)?;
            if let Some(router) = vpc.router.as_ref() {
                if router.name.trim().is_empty() {
                    bail!("router name must be non-empty");
                }
                if router.gateway_cidr.trim().is_empty()
                    || router.mac_address.trim().is_empty()
                    || router.external_ip.trim().is_empty()
                {
                    bail!("router {} must set gateway_cidr, mac_address, and external_ip", router.name);
                }
                validate_router_spec(vpc, router)?;
            }
            validate_unique_names("subnet", vpc.subnets.iter().map(|subnet| subnet.name.as_str()))?;
            let mut seen_subnet_cidrs: Vec<(&str, (Ipv4Addr, u8))> = Vec::new();
            for subnet in &vpc.subnets {
                let subnet_cidr = parse_ipv4_cidr(&subnet.cidr_block)?;
                if !cidr_contains_cidr(vpc_cidr, subnet_cidr) {
                    bail!(
                        "subnet {} cidr_block must fall inside vpc {} cidr_block",
                        subnet.name,
                        vpc.name
                    );
                }
                if let Some((other_name, _)) = seen_subnet_cidrs
                    .iter()
                    .find(|(_, other_cidr)| cidr_overlaps(*other_cidr, subnet_cidr))
                {
                    bail!(
                        "subnet {} overlaps subnet {} in vpc {}",
                        subnet.name,
                        other_name,
                        vpc.name
                    );
                }
                seen_subnet_cidrs.push((subnet.name.as_str(), subnet_cidr));
                let gateway_ip = subnet
                    .gateway_ip
                    .as_deref()
                    .map(parse_ipv4)
                    .transpose()?;
                if let Some(gateway_ip) = gateway_ip {
                    if !cidr_contains(subnet_cidr, gateway_ip) {
                        bail!(
                            "subnet {} gateway_ip must fall inside subnet cidr_block",
                            subnet.name
                        );
                    }
                }
                validate_unique_names("port", subnet.ports.iter().map(|port| port.name.as_str()))?;
                let mut fixed_ips = HashSet::new();
                for port in &subnet.ports {
                    for security_group in &port.security_groups {
                        if !security_group_names.contains(security_group.as_str()) {
                            bail!(
                                "port {} references unknown security group {}",
                                port.name,
                                security_group
                            );
                        }
                    }
                    if let Some(ip_address) = port.ip_address.as_deref() {
                        let ip_address = parse_ipv4(ip_address)?;
                        if !cidr_contains(subnet_cidr, ip_address) {
                            bail!(
                                "port {} ip_address must fall inside subnet {} cidr_block",
                                port.name,
                                subnet.name
                            );
                        }
                        if gateway_ip.map(|gateway_ip| gateway_ip == ip_address).unwrap_or(false) {
                            bail!(
                                "port {} ip_address cannot reuse subnet {} gateway_ip",
                                port.name,
                                subnet.name
                            );
                        }
                        if !fixed_ips.insert(ip_address) {
                            bail!(
                                "subnet {} declares duplicate fixed ip_address {}",
                                subnet.name,
                                ip_address
                            );
                        }
                    }
                }
            }
        }
    }

    Ok(())
}

fn validate_unique_names<'a>(
    resource_kind: &str,
    names: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
    let mut seen = HashSet::new();
    for name in names {
        if name.trim().is_empty() {
            bail!("{} name must be non-empty", resource_kind);
        }
        if !seen.insert(name) {
            bail!("duplicate {} name {}", resource_kind, name);
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_duplicate_tenant_scope() {
        let config = TenantNetworkConfig {
            tenants: vec![
                TenantSpec {
                    org_id: "org-1".into(),
                    project_id: "proj-1".into(),
                    security_groups: Vec::new(),
                    service_ip_pools: Vec::new(),
                    vpcs: Vec::new(),
                },
                TenantSpec {
                    org_id: "org-1".into(),
                    project_id: "proj-1".into(),
                    security_groups: Vec::new(),
                    service_ip_pools: Vec::new(),
                    vpcs: Vec::new(),
                },
            ],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn resolves_rule_remote_group_by_name() {
        let mut security_group_ids = HashMap::new();
        security_group_ids.insert("web".to_string(), "sg-1".to_string());

        let fingerprint = fingerprint_desired_rule(
            &SecurityGroupRuleSpec {
                direction: "ingress".into(),
                protocol: Some("tcp".into()),
                port_range_min: Some(443),
                port_range_max: Some(443),
                remote_cidr: None,
                remote_group: Some("web".into()),
                description: Some("allow web".into()),
            },
            &security_group_ids,
        )
        .unwrap();

        assert_eq!(fingerprint.direction, RuleDirection::Ingress as i32);
        assert_eq!(fingerprint.protocol, IpProtocol::Tcp as i32);
        assert_eq!(fingerprint.remote_group_id, "sg-1");
    }

    #[test]
    fn rejects_port_reference_to_unknown_security_group() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: vec![SecurityGroupSpec {
                    name: "web".into(),
                    description: None,
                    rules: Vec::new(),
                }],
                service_ip_pools: Vec::new(),
                vpcs: vec![VpcSpec {
                    name: "vpc-a".into(),
                    cidr_block: "10.0.0.0/16".into(),
                    description: None,
                    router: None,
                    subnets: vec![SubnetSpec {
                        name: "subnet-a".into(),
                        cidr_block: "10.0.1.0/24".into(),
                        gateway_ip: None,
                        description: None,
                        dhcp_enabled: None,
                        ports: vec![PortSpec {
                            name: "port-a".into(),
                            description: None,
                            ip_address: None,
                            security_groups: vec!["db".into()],
                            admin_state_up: None,
                        }],
                    }],
                }],
            }],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn rejects_invalid_service_ip_pool_type() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: Vec::new(),
                service_ip_pools: vec![ServiceIpPoolSpec {
                    name: "lb".into(),
                    cidr_block: "10.200.0.0/24".into(),
                    description: None,
                    pool_type: Some("bogus".into()),
                }],
                vpcs: Vec::new(),
            }],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn rejects_router_gateway_outside_vpc() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: Vec::new(),
                service_ip_pools: Vec::new(),
                vpcs: vec![VpcSpec {
                    name: "vpc-a".into(),
                    cidr_block: "10.0.0.0/16".into(),
                    description: None,
                    router: Some(RouterSpec {
                        name: "edge".into(),
                        gateway_cidr: "10.1.0.1/24".into(),
                        mac_address: "02:00:00:00:00:01".into(),
                        external_ip: "203.0.113.10".into(),
                        description: None,
                    }),
                    subnets: Vec::new(),
                }],
            }],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn rejects_router_with_invalid_mac() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: Vec::new(),
                service_ip_pools: Vec::new(),
                vpcs: vec![VpcSpec {
                    name: "vpc-a".into(),
                    cidr_block: "10.0.0.0/16".into(),
                    description: None,
                    router: Some(RouterSpec {
                        name: "edge".into(),
                        gateway_cidr: "10.0.0.1/24".into(),
                        mac_address: "bad-mac".into(),
                        external_ip: "203.0.113.10".into(),
                        description: None,
                    }),
                    subnets: Vec::new(),
                }],
            }],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn rejects_subnet_outside_vpc() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: Vec::new(),
                service_ip_pools: Vec::new(),
                vpcs: vec![VpcSpec {
                    name: "vpc-a".into(),
                    cidr_block: "10.0.0.0/16".into(),
                    description: None,
                    router: None,
                    subnets: vec![SubnetSpec {
                        name: "subnet-a".into(),
                        cidr_block: "10.1.0.0/24".into(),
                        gateway_ip: None,
                        description: None,
                        dhcp_enabled: None,
                        ports: Vec::new(),
                    }],
                }],
            }],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn rejects_overlapping_subnets() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: Vec::new(),
                service_ip_pools: Vec::new(),
                vpcs: vec![VpcSpec {
                    name: "vpc-a".into(),
                    cidr_block: "10.0.0.0/16".into(),
                    description: None,
                    router: None,
                    subnets: vec![
                        SubnetSpec {
                            name: "subnet-a".into(),
                            cidr_block: "10.0.1.0/24".into(),
                            gateway_ip: None,
                            description: None,
                            dhcp_enabled: None,
                            ports: Vec::new(),
                        },
                        SubnetSpec {
                            name: "subnet-b".into(),
                            cidr_block: "10.0.1.128/25".into(),
                            gateway_ip: None,
                            description: None,
                            dhcp_enabled: None,
                            ports: Vec::new(),
                        },
                    ],
                }],
            }],
        };

        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn rejects_duplicate_fixed_port_ip() {
        let config = TenantNetworkConfig {
            tenants: vec![TenantSpec {
                org_id: "org-1".into(),
                project_id: "proj-1".into(),
                security_groups: Vec::new(),
                service_ip_pools: Vec::new(),
                vpcs: vec![VpcSpec {
                    name: "vpc-a".into(),
                    cidr_block: "10.0.0.0/16".into(),
                    description: None,
                    router: None,
                    subnets: vec![SubnetSpec {
                        name: "subnet-a".into(),
                        cidr_block: "10.0.1.0/24".into(),
                        gateway_ip: Some("10.0.1.1".into()),
                        description: None,
                        dhcp_enabled: None,
                        ports: vec![
                            PortSpec {
                                name: "port-a".into(),
                                description: None,
                                ip_address: Some("10.0.1.10".into()),
                                security_groups: Vec::new(),
                                admin_state_up: None,
                            },
                            PortSpec {
                                name: "port-b".into(),
                                description: None,
                                ip_address: Some("10.0.1.10".into()),
                                security_groups: Vec::new(),
                                admin_state_up: None,
                            },
                        ],
                    }],
                }],
            }],
        };

        assert!(validate_config(&config).is_err());
    }
}
