use std::sync::Arc;
use std::time::UNIX_EPOCH;

use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};

use crate::ctx::ServerCtx;
use crate::forward::{forward_query, Upstream};
use crate::query_log::QueryLogFilter;
use crate::question::QueryType;
use crate::stats::QueryPath;

include!(concat!(env!("OUT_DIR"), "/locales.rs")); // LOCALES: &[(code, json)]

const DASHBOARD_HTML: &str = include_str!("../site/dashboard.html");
const FONTS_CSS: &str = include_str!("../site/fonts/fonts.css");
const FONT_DM_SANS: &[u8] = include_bytes!("../site/fonts/dm-sans-latin.woff2");
const FONT_DM_SANS_ITALIC: &[u8] = include_bytes!("../site/fonts/dm-sans-italic-latin.woff2");
const FONT_INSTRUMENT: &[u8] = include_bytes!("../site/fonts/instrument-serif-latin.woff2");
const FONT_INSTRUMENT_ITALIC: &[u8] =
    include_bytes!("../site/fonts/instrument-serif-italic-latin.woff2");
const FONT_JETBRAINS: &[u8] = include_bytes!("../site/fonts/jetbrains-mono-latin.woff2");

pub fn router(ctx: Arc<ServerCtx>) -> Router {
    Router::new()
        .route("/", get(dashboard))
        .route("/overrides", post(create_overrides))
        .route("/overrides", get(list_overrides))
        .route("/overrides", delete(clear_overrides))
        .route("/overrides/environment", post(load_environment))
        .route("/overrides/{domain}", get(get_override))
        .route("/overrides/{domain}", delete(remove_override))
        .route("/diagnose/{domain}", get(diagnose))
        .route("/query-log", get(query_log))
        .route("/stats", get(stats))
        .route("/cache", get(list_cache))
        .route("/cache", delete(flush_cache))
        .route("/cache/{domain}", delete(flush_cache_domain))
        .route("/health", get(health))
        .route("/blocking/stats", get(blocking_stats))
        .route("/blocking/toggle", put(blocking_toggle))
        .route("/blocking/pause", post(blocking_pause))
        .route("/blocking/unpause", post(blocking_unpause))
        .route("/blocking/allowlist", get(blocking_allowlist))
        .route("/blocking/allowlist", post(blocking_allowlist_add))
        .route("/blocking/check/{domain}", get(blocking_check))
        .route(
            "/blocking/allowlist/{domain}",
            delete(blocking_allowlist_remove),
        )
        .route("/blocking/blocklist", get(blocking_blocklist))
        .route("/blocking/blocklist", post(blocking_blocklist_add))
        .route(
            "/blocking/blocklist/{domain}",
            delete(blocking_blocklist_remove),
        )
        .route("/rebind", get(rebind_status))
        .route("/rebind/toggle", put(rebind_toggle))
        .route("/rebind/allowlist", post(rebind_allowlist_add))
        .route(
            "/rebind/allowlist/{domain}",
            delete(rebind_allowlist_remove),
        )
        .route("/services", get(list_services))
        .route("/services", post(create_service))
        .route("/services/{name}", delete(remove_service))
        .route("/services/{name}/routes", get(list_routes))
        .route("/services/{name}/routes", post(add_route))
        .route("/services/{name}/routes", delete(remove_route))
        .route("/ca.pem", get(serve_ca))
        .route("/qr", get(serve_qr))
        .route("/locales/{file}", get(serve_locale))
        .route("/fonts/fonts.css", get(serve_fonts_css))
        .route(
            "/fonts/dm-sans-latin.woff2",
            get(|| async { serve_font(FONT_DM_SANS) }),
        )
        .route(
            "/fonts/dm-sans-italic-latin.woff2",
            get(|| async { serve_font(FONT_DM_SANS_ITALIC) }),
        )
        .route(
            "/fonts/instrument-serif-latin.woff2",
            get(|| async { serve_font(FONT_INSTRUMENT) }),
        )
        .route(
            "/fonts/instrument-serif-italic-latin.woff2",
            get(|| async { serve_font(FONT_INSTRUMENT_ITALIC) }),
        )
        .route(
            "/fonts/jetbrains-mono-latin.woff2",
            get(|| async { serve_font(FONT_JETBRAINS) }),
        )
        .with_state(ctx)
}

async fn dashboard() -> impl IntoResponse {
    // Revalidate each load so browsers don't keep serving a stale
    // dashboard across numa upgrades.
    (
        [
            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
            (header::CACHE_CONTROL, "no-cache"),
        ],
        DASHBOARD_HTML,
    )
}

// --- Request/Response DTOs ---

#[derive(Deserialize)]
struct CreateOverrideRequest {
    domain: String,
    target: String,
    #[serde(default = "default_ttl")]
    ttl: u32,
    duration_secs: Option<u64>,
}

fn default_ttl() -> u32 {
    60
}

#[derive(Serialize)]
struct OverrideResponse {
    domain: String,
    target: String,
    record_type: String,
    ttl: u32,
    remaining_secs: Option<u64>,
}

impl From<&crate::override_store::OverrideEntry> for OverrideResponse {
    fn from(e: &crate::override_store::OverrideEntry) -> Self {
        OverrideResponse {
            domain: e.domain.clone(),
            target: e.target.clone(),
            record_type: e.query_type.as_str().to_string(),
            ttl: e.ttl,
            remaining_secs: e.remaining_secs(),
        }
    }
}

#[derive(Deserialize)]
struct EnvironmentRequest {
    #[serde(default)]
    duration_secs: Option<u64>,
    overrides: Vec<CreateOverrideRequest>,
}

#[derive(Serialize)]
struct EnvironmentResponse {
    created: usize,
}

#[derive(Deserialize)]
struct QueryLogParams {
    domain: Option<String>,
    r#type: Option<String>,
    path: Option<String>,
    limit: Option<usize>,
}

#[derive(Serialize)]
struct QueryLogResponse {
    seq: u64,
    timestamp_epoch: f64,
    src: String,
    domain: String,
    query_type: String,
    path: String,
    transport: String,
    rescode: String,
    latency_ms: f64,
    dnssec: String,
    rebind_stripped: bool,
}

#[derive(Serialize)]
struct StatsResponse {
    version: &'static str,
    uptime_secs: u64,
    upstream: String,
    mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
    config_path: String,
    config_found: bool,
    data_dir: String,
    proxy_tld: String,
    dnssec: bool,
    srtt: bool,
    rebind: bool,
    queries: QueriesStats,
    transport: TransportStats,
    upstream_transport: UpstreamTransportStats,
    cache: CacheStats,
    overrides: OverrideStats,
    blocking: BlockingStatsResponse,
    lan: LanStatsResponse,
    mobile: MobileStatsResponse,
    proxy_protocol: ProxyProtocolStats,
    memory: MemoryStats,
}

#[derive(Serialize)]
struct ProxyProtocolStats {
    accepted: u64,
    rejected_untrusted: u64,
    rejected_signature: u64,
    local_command: u64,
    timeout: u64,
}

#[derive(Serialize)]
struct TransportStats {
    udp: u64,
    tcp: u64,
    dot: u64,
    doh: u64,
}

#[derive(Serialize)]
struct UpstreamTransportStats {
    udp: u64,
    tcp: u64,
    doh: u64,
    dot: u64,
    odoh: u64,
}

#[derive(Serialize)]
struct MobileStatsResponse {
    enabled: bool,
    port: u16,
}

#[derive(Serialize)]
struct LanStatsResponse {
    enabled: bool,
    peers: usize,
}

#[derive(Serialize)]
struct QueriesStats {
    total: u64,
    forwarded: u64,
    upstream: u64,
    recursive: u64,
    coalesced: u64,
    cached: u64,
    local: u64,
    overridden: u64,
    blocked: u64,
    errors: u64,
    rebind_stripped: u64,
}

#[derive(Serialize)]
struct CacheStats {
    entries: usize,
    max_entries: usize,
}

#[derive(Serialize)]
struct OverrideStats {
    active: usize,
}

#[derive(Serialize)]
struct BlockingStatsResponse {
    enabled: bool,
    paused: bool,
    domains_loaded: usize,
    allowlist_size: usize,
}

#[derive(Serialize)]
struct MemoryStats {
    cache_bytes: usize,
    blocklist_bytes: usize,
    query_log_bytes: usize,
    query_log_entries: usize,
    srtt_bytes: usize,
    srtt_entries: usize,
    overrides_bytes: usize,
    total_estimated_bytes: usize,
    process_memory_bytes: usize,
}

#[derive(Serialize)]
struct DiagnoseResponse {
    domain: String,
    query_type: String,
    steps: Vec<DiagnoseStep>,
}

#[derive(Serialize)]
struct DiagnoseStep {
    source: String,
    matched: bool,
    detail: Option<String>,
}

#[derive(Serialize)]
struct CacheEntryResponse {
    domain: String,
    query_type: String,
    ttl_remaining: u32,
}

// --- Handlers ---

async fn create_overrides(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<serde_json::Value>,
) -> Result<(StatusCode, Json<Vec<OverrideResponse>>), (StatusCode, String)> {
    let requests: Vec<CreateOverrideRequest> = if req.is_array() {
        serde_json::from_value(req).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
    } else {
        let single: CreateOverrideRequest =
            serde_json::from_value(req).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
        vec![single]
    };

    // Parse and validate all requests before acquiring the lock
    let parsed: Vec<_> = requests
        .into_iter()
        .map(|req| {
            let domain_lower = req.domain.to_lowercase();
            Ok((domain_lower, req.target, req.ttl, req.duration_secs))
        })
        .collect::<Result<Vec<_>, (StatusCode, String)>>()?;

    let mut store = ctx.overrides.write().unwrap();
    let mut responses = Vec::with_capacity(parsed.len());

    for (domain, target, ttl, duration_secs) in parsed {
        let qtype = store
            .insert(&domain, &target, ttl, duration_secs)
            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;

        responses.push(OverrideResponse {
            domain,
            target,
            record_type: qtype.as_str().to_string(),
            ttl,
            remaining_secs: duration_secs,
        });
    }

    Ok((StatusCode::CREATED, Json(responses)))
}

async fn list_overrides(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<OverrideResponse>> {
    let store = ctx.overrides.read().unwrap();
    let entries: Vec<OverrideResponse> = store
        .list()
        .into_iter()
        .map(OverrideResponse::from)
        .collect();
    Json(entries)
}

async fn get_override(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> Result<Json<OverrideResponse>, StatusCode> {
    let store = ctx.overrides.read().unwrap();
    let entry = store.get(&domain).ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(OverrideResponse::from(entry)))
}

async fn remove_override(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> StatusCode {
    let mut store = ctx.overrides.write().unwrap();
    if store.remove(&domain) {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

async fn clear_overrides(State(ctx): State<Arc<ServerCtx>>) -> StatusCode {
    ctx.overrides.write().unwrap().clear();
    StatusCode::NO_CONTENT
}

async fn load_environment(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<EnvironmentRequest>,
) -> Result<(StatusCode, Json<EnvironmentResponse>), (StatusCode, String)> {
    let mut store = ctx.overrides.write().unwrap();

    for entry in &req.overrides {
        let duration = entry.duration_secs.or(req.duration_secs);
        store
            .insert(&entry.domain, &entry.target, entry.ttl, duration)
            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
    }

    Ok((
        StatusCode::CREATED,
        Json(EnvironmentResponse {
            created: req.overrides.len(),
        }),
    ))
}

async fn diagnose(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> Json<DiagnoseResponse> {
    let domain_lower = domain.to_lowercase();
    let qtype = QueryType::A;
    let mut steps = Vec::new();

    // Check overrides
    {
        let store = ctx.overrides.read().unwrap();
        let entry = store.get(&domain_lower);
        steps.push(DiagnoseStep {
            source: "override".to_string(),
            matched: entry.is_some(),
            detail: entry
                .map(|e| format!("{} -> {} ({})", e.domain, e.target, e.query_type.as_str())),
        });
    }

    // Check blocklist
    {
        let bl = ctx.blocklist.read().unwrap();
        let blocked = bl.is_blocked(&domain_lower);
        steps.push(DiagnoseStep {
            source: "blocklist".to_string(),
            matched: blocked,
            detail: if blocked {
                Some("domain is in blocklist".to_string())
            } else {
                None
            },
        });
    }

    let zone_hit = ctx.zone_map.lookup(domain_lower.as_str(), qtype);
    steps.push(DiagnoseStep {
        source: "local_zone".to_string(),
        matched: zone_hit.is_some(),
        detail: zone_hit.map(|records| format!("{} records", records.len())),
    });

    // Check cache
    {
        let cache = ctx.cache.read().unwrap();
        let cached = cache.lookup(&domain_lower, qtype);
        steps.push(DiagnoseStep {
            source: "cache".to_string(),
            matched: cached.is_some(),
            detail: cached.map(|p| format!("{} answers", p.answers.len())),
        });
    }

    // Check upstream (async, no locks held)
    let upstream = ctx.upstream_pool.lock().unwrap().preferred().cloned();
    let (upstream_matched, upstream_detail) = if let Some(ref u) = upstream {
        forward_query_for_diagnose(&domain_lower, u, ctx.timeout).await
    } else {
        (false, "no upstream configured".to_string())
    };
    steps.push(DiagnoseStep {
        source: "upstream".to_string(),
        matched: upstream_matched,
        detail: Some(upstream_detail),
    });

    Json(DiagnoseResponse {
        domain: domain_lower,
        query_type: qtype.as_str().to_string(),
        steps,
    })
}

async fn forward_query_for_diagnose(
    domain: &str,
    upstream: &Upstream,
    timeout: std::time::Duration,
) -> (bool, String) {
    use crate::packet::DnsPacket;

    let query = DnsPacket::query(0xBEEF, domain, QueryType::A);

    match forward_query(&query, upstream, timeout).await {
        Ok(resp) => (
            true,
            format!(
                "{} ({} answers)",
                resp.header.rescode.as_str(),
                resp.answers.len()
            ),
        ),
        Err(e) => (false, format!("error: {}", e)),
    }
}

async fn query_log(
    State(ctx): State<Arc<ServerCtx>>,
    Query(params): Query<QueryLogParams>,
) -> Json<Vec<QueryLogResponse>> {
    let qtype = params.r#type.as_deref().and_then(QueryType::parse_str);
    let path = params.path.as_deref().and_then(QueryPath::parse_str);

    let filter = QueryLogFilter {
        domain: params.domain,
        query_type: qtype,
        path,
        limit: params.limit,
    };

    let raw_entries: Vec<QueryLogResponse> = {
        let log = ctx.query_log.lock().unwrap();
        log.query(&filter)
            .into_iter()
            .map(|e| {
                let epoch = e
                    .timestamp
                    .duration_since(UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_secs_f64();
                QueryLogResponse {
                    seq: e.seq,
                    timestamp_epoch: epoch,
                    src: e.src_addr.to_string(),
                    domain: e.domain.clone(),
                    query_type: e.query_type.as_str().to_string(),
                    path: e.path.as_str().to_string(),
                    transport: e.transport.as_str().to_string(),
                    rescode: e.rescode.as_str().to_string(),
                    latency_ms: e.latency_us as f64 / 1000.0,
                    dnssec: e.dnssec.as_str().to_string(),
                    rebind_stripped: e.rebind_stripped,
                }
            })
            .collect()
    };

    Json(raw_entries)
}

async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
    let snap = ctx.stats.lock().unwrap().snapshot();
    let (cache_len, cache_max, cache_bytes) = {
        let cache = ctx.cache.read().unwrap();
        (cache.len(), cache.max_entries(), cache.heap_bytes())
    };
    let (override_count, overrides_bytes) = {
        let ov = ctx.overrides.read().unwrap();
        (ov.active_count(), ov.heap_bytes())
    };
    let (bl_stats, blocklist_bytes) = {
        let bl = ctx.blocklist.read().unwrap();
        (bl.stats(), bl.heap_bytes())
    };
    let (query_log_bytes, query_log_entries) = {
        let log = ctx.query_log.lock().unwrap();
        (log.heap_bytes(), log.len())
    };
    let (srtt_bytes, srtt_entries, srtt_enabled) = {
        let s = ctx.srtt.read().unwrap();
        (s.heap_bytes(), s.len(), s.is_enabled())
    };

    let total_estimated =
        cache_bytes + blocklist_bytes + query_log_bytes + srtt_bytes + overrides_bytes;

    let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
        "recursive (root hints)".to_string()
    } else {
        ctx.upstream_pool.lock().unwrap().label()
    };

    Json(StatsResponse {
        version: crate::version(),
        uptime_secs: snap.uptime_secs,
        upstream,
        mode: ctx.upstream_mode.as_str(),
        config_path: ctx.config_path.clone(),
        config_found: ctx.config_found,
        data_dir: ctx.data_dir.to_string_lossy().to_string(),
        proxy_tld: ctx.proxy_tld.clone(),
        dnssec: ctx.dnssec_enabled,
        srtt: srtt_enabled,
        rebind: ctx.rebind.read().unwrap().is_enabled(),
        queries: QueriesStats {
            total: snap.total,
            forwarded: snap.forwarded,
            upstream: snap.upstream,
            recursive: snap.recursive,
            coalesced: snap.coalesced,
            cached: snap.cached,
            local: snap.local,
            overridden: snap.overridden,
            blocked: snap.blocked,
            errors: snap.errors,
            rebind_stripped: snap.rebind_stripped,
        },
        transport: TransportStats {
            udp: snap.transport_udp,
            tcp: snap.transport_tcp,
            dot: snap.transport_dot,
            doh: snap.transport_doh,
        },
        upstream_transport: UpstreamTransportStats {
            udp: snap.upstream_transport_udp,
            tcp: snap.upstream_transport_tcp,
            doh: snap.upstream_transport_doh,
            dot: snap.upstream_transport_dot,
            odoh: snap.upstream_transport_odoh,
        },
        cache: CacheStats {
            entries: cache_len,
            max_entries: cache_max,
        },
        overrides: OverrideStats {
            active: override_count,
        },
        blocking: BlockingStatsResponse {
            enabled: bl_stats.enabled,
            paused: bl_stats.paused,
            domains_loaded: bl_stats.domains_loaded,
            allowlist_size: bl_stats.allowlist_size,
        },
        lan: LanStatsResponse {
            enabled: ctx.lan_enabled,
            peers: ctx.lan_peers.lock().unwrap().list().len(),
        },
        mobile: MobileStatsResponse {
            enabled: ctx.mobile_enabled,
            port: ctx.mobile_port,
        },
        proxy_protocol: ProxyProtocolStats {
            accepted: snap.proxy_v2_accepted,
            rejected_untrusted: snap.proxy_v2_rejected_untrusted,
            rejected_signature: snap.proxy_v2_rejected_signature,
            local_command: snap.proxy_v2_local_command,
            timeout: snap.proxy_v2_timeout,
        },
        memory: MemoryStats {
            cache_bytes,
            blocklist_bytes,
            query_log_bytes,
            query_log_entries,
            srtt_bytes,
            srtt_entries,
            overrides_bytes,
            total_estimated_bytes: total_estimated,
            process_memory_bytes: crate::stats::process_memory_bytes(),
        },
    })
}

async fn list_cache(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<CacheEntryResponse>> {
    let cache = ctx.cache.read().unwrap();
    let entries: Vec<CacheEntryResponse> = cache
        .list()
        .into_iter()
        .map(|info| CacheEntryResponse {
            domain: info.domain,
            query_type: info.query_type.as_str().to_string(),
            ttl_remaining: info.ttl_remaining,
        })
        .collect();
    Json(entries)
}

async fn flush_cache(State(ctx): State<Arc<ServerCtx>>) -> StatusCode {
    ctx.cache.write().unwrap().clear();
    StatusCode::NO_CONTENT
}

async fn flush_cache_domain(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> StatusCode {
    ctx.cache.write().unwrap().remove(&domain);
    StatusCode::NO_CONTENT
}

/// Enriched `/health` handler shared between the main API and the mobile API.
///
/// Returns the cached `HealthMeta` assembled with live fields (LAN IP,
/// uptime). Backward compatible with the previous minimal response in
/// that `status` is still the first field and `"ok"` is still the value.
/// The iOS companion app's `HealthInfo` Swift struct decodes the full
/// response; any HTTP client asserting only on `"status"` keeps working.
pub async fn health(State(ctx): State<Arc<ServerCtx>>) -> Json<crate::health::HealthResponse> {
    let lan_ip = Some(*ctx.lan_ip.lock().unwrap());
    Json(crate::health::HealthResponse::build(
        &ctx.health_meta,
        lan_ip,
    ))
}

// --- Blocking handlers ---

async fn blocking_stats(State(ctx): State<Arc<ServerCtx>>) -> Json<serde_json::Value> {
    let stats = ctx.blocklist.read().unwrap().stats();
    Json(serde_json::json!({
        "enabled": stats.enabled,
        "paused": stats.paused,
        "domains_loaded": stats.domains_loaded,
        "allowlist_size": stats.allowlist_size,
        "health": stats.health,
        "list_sources": stats.list_sources,
        "last_refresh_secs_ago": stats.last_refresh_secs_ago,
    }))
}

#[derive(Deserialize)]
struct BlockingToggleRequest {
    enabled: bool,
}

async fn blocking_toggle(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<BlockingToggleRequest>,
) -> Json<serde_json::Value> {
    ctx.blocklist.write().unwrap().set_enabled(req.enabled);
    Json(serde_json::json!({ "enabled": req.enabled }))
}

#[derive(Deserialize)]
struct BlockingPauseRequest {
    #[serde(default = "default_pause_minutes")]
    minutes: u64,
}

fn default_pause_minutes() -> u64 {
    5
}

async fn blocking_pause(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<BlockingPauseRequest>,
) -> Json<serde_json::Value> {
    ctx.blocklist.write().unwrap().pause(req.minutes * 60);
    Json(serde_json::json!({ "paused_minutes": req.minutes }))
}

async fn blocking_unpause(State(ctx): State<Arc<ServerCtx>>) -> Json<serde_json::Value> {
    ctx.blocklist.write().unwrap().unpause();
    Json(serde_json::json!({ "paused": false }))
}

async fn blocking_check(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> Json<crate::blocklist::BlockCheckResult> {
    let result = ctx.blocklist.read().unwrap().check(&domain);
    Json(result)
}

async fn blocking_allowlist(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<String>> {
    let list = ctx.blocklist.read().unwrap().allowlist();
    Json(list)
}

#[derive(Deserialize)]
struct AllowlistRequest {
    domain: String,
}

async fn blocking_allowlist_add(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<AllowlistRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
    ctx.blocklist.write().unwrap().add_to_allowlist(&req.domain);
    (
        StatusCode::CREATED,
        Json(serde_json::json!({ "allowed": req.domain })),
    )
}

async fn blocking_allowlist_remove(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> StatusCode {
    if ctx
        .blocklist
        .write()
        .unwrap()
        .remove_from_allowlist(&domain)
    {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

async fn blocking_blocklist(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<String>> {
    let list = ctx.blocklist.read().unwrap().manual_blocklist();
    Json(list)
}

async fn blocking_blocklist_add(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<AllowlistRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
    ctx.blocklist.write().unwrap().add_to_blocklist(&req.domain);
    (
        StatusCode::CREATED,
        Json(serde_json::json!({ "blocked": req.domain })),
    )
}

async fn blocking_blocklist_remove(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> StatusCode {
    if ctx
        .blocklist
        .write()
        .unwrap()
        .remove_from_blocklist(&domain)
    {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

// --- DNS rebinding-protection handlers ---

#[derive(Serialize)]
struct RebindResponse {
    enabled: bool,
    ranges: Vec<String>,
    allowlist: Vec<String>,
}

async fn rebind_status(State(ctx): State<Arc<ServerCtx>>) -> Json<RebindResponse> {
    let r = ctx.rebind.read().unwrap();
    Json(RebindResponse {
        enabled: r.is_enabled(),
        ranges: r.ranges().to_vec(),
        allowlist: r.allowlist(),
    })
}

#[derive(Deserialize)]
struct RebindToggleRequest {
    enabled: bool,
}

async fn rebind_toggle(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<RebindToggleRequest>,
) -> Json<serde_json::Value> {
    ctx.rebind.write().unwrap().set_enabled(req.enabled);
    Json(serde_json::json!({ "enabled": req.enabled }))
}

async fn rebind_allowlist_add(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<AllowlistRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
    ctx.rebind.write().unwrap().add_to_allowlist(&req.domain);
    (
        StatusCode::CREATED,
        Json(serde_json::json!({ "allowed": req.domain })),
    )
}

async fn rebind_allowlist_remove(
    State(ctx): State<Arc<ServerCtx>>,
    Path(domain): Path<String>,
) -> StatusCode {
    if ctx.rebind.write().unwrap().remove_from_allowlist(&domain) {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

// --- Service proxy handlers ---

#[derive(Serialize)]
struct ServiceResponse {
    name: String,
    target_port: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    target_host: Option<String>,
    url: String,
    healthy: bool,
    lan_accessible: bool,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    routes: Vec<crate::service_store::RouteEntry>,
    source: String,
}

#[derive(Deserialize)]
struct CreateServiceRequest {
    name: String,
    target_port: u16,
    #[serde(default)]
    target_host: Option<String>,
}

async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceResponse>> {
    let entries: Vec<(crate::service_store::ServiceEntry, &'static str)> = {
        let store = ctx.services.lock().unwrap();
        store
            .list()
            .into_iter()
            .map(|e| {
                let source = if store.is_config_service(&e.name) {
                    "config"
                } else {
                    "api"
                };
                (e.clone(), source)
            })
            .collect()
    };
    let tld = &ctx.proxy_tld;

    let lan_ip = crate::lan::detect_lan_ip();

    let check_futures = entries.iter().map(|(e, _)| {
        let port = e.target_port;
        let host = e.target_host.clone().unwrap_or_else(|| "localhost".into());
        let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port));
        async move {
            let healthy = check_tcp((host.as_str(), port)).await;
            let lan_accessible = match lan_addr {
                Some(addr) => check_tcp(addr).await,
                None => false,
            };
            (healthy, lan_accessible)
        }
    });
    let check_results = futures::future::join_all(check_futures).await;

    let scheme = if ctx.tls_byo { "https" } else { "http" };
    let results = entries
        .into_iter()
        .zip(check_results)
        .map(|((e, source), (healthy, lan_accessible))| ServiceResponse {
            url: format!("{}://{}.{}", scheme, e.name, tld),
            name: e.name,
            target_port: e.target_port,
            target_host: e.target_host,
            healthy,
            lan_accessible,
            routes: e.routes,
            source: source.to_string(),
        })
        .collect();
    Json(results)
}

async fn create_service(
    State(ctx): State<Arc<ServerCtx>>,
    Json(req): Json<CreateServiceRequest>,
) -> Result<(StatusCode, Json<ServiceResponse>), (StatusCode, String)> {
    let name = req.name.to_lowercase();

    // Validate name: alphanumeric + hyphens only, 1-63 chars
    if name.is_empty() || name.len() > 63 {
        return Err((
            StatusCode::BAD_REQUEST,
            "name must be 1-63 characters".into(),
        ));
    }
    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
        return Err((
            StatusCode::BAD_REQUEST,
            "name must contain only alphanumeric characters and hyphens".into(),
        ));
    }
    if req.target_port == 0 {
        return Err((StatusCode::BAD_REQUEST, "target_port must be > 0".into()));
    }
    let target_host = match req.target_host.as_deref().map(str::trim) {
        Some("") | Some("localhost") | Some("127.0.0.1") | None => None,
        Some(h) => {
            if h.len() > 253 {
                return Err((
                    StatusCode::BAD_REQUEST,
                    "target_host must be at most 253 characters".into(),
                ));
            }
            // Reject control chars + whitespace + obvious scheme/path bleed.
            if h.chars()
                .any(|c| c.is_control() || c.is_whitespace() || c == '/' || c == ':')
            {
                return Err((
                    StatusCode::BAD_REQUEST,
                    "target_host must be a bare hostname or IP (no scheme, port, or path)".into(),
                ));
            }
            Some(h.to_string())
        }
    };

    let tld = &ctx.proxy_tld;
    let is_new = !ctx.services.lock().unwrap().has_name(&name);
    ctx.services
        .lock()
        .unwrap()
        .insert(&name, req.target_port, target_host.clone());
    if is_new {
        crate::tls::regenerate_tls(&ctx);
    }

    let probe_host = target_host.as_deref().unwrap_or("localhost");
    let lan_addr =
        crate::lan::detect_lan_ip().map(|ip| std::net::SocketAddr::new(ip.into(), req.target_port));
    let (healthy, lan_accessible) = tokio::join!(check_tcp((probe_host, req.target_port)), async {
        match lan_addr {
            Some(a) => check_tcp(a).await,
            None => false,
        }
    });
    let scheme = if ctx.tls_byo { "https" } else { "http" };
    Ok((
        StatusCode::CREATED,
        Json(ServiceResponse {
            url: format!("{}://{}.{}", scheme, name, tld),
            name,
            target_port: req.target_port,
            target_host,
            healthy,
            lan_accessible,
            routes: Vec::new(),
            source: "api".to_string(),
        }),
    ))
}

async fn remove_service(State(ctx): State<Arc<ServerCtx>>, Path(name): Path<String>) -> StatusCode {
    if name.eq_ignore_ascii_case("numa") {
        return StatusCode::FORBIDDEN;
    }
    let removed = ctx.services.lock().unwrap().remove(&name);
    if removed {
        crate::tls::regenerate_tls(&ctx);
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

// --- Route handlers ---

#[derive(Deserialize)]
struct AddRouteRequest {
    path: String,
    port: u16,
    #[serde(default)]
    strip: bool,
}

#[derive(Deserialize)]
struct RemoveRouteRequest {
    path: String,
}

async fn list_routes(
    State(ctx): State<Arc<ServerCtx>>,
    Path(name): Path<String>,
) -> Result<Json<Vec<crate::service_store::RouteEntry>>, StatusCode> {
    let store = ctx.services.lock().unwrap();
    match store.lookup(&name) {
        Some(entry) => Ok(Json(entry.routes.clone())),
        None => Err(StatusCode::NOT_FOUND),
    }
}

async fn add_route(
    State(ctx): State<Arc<ServerCtx>>,
    Path(name): Path<String>,
    Json(req): Json<AddRouteRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
    if req.path.is_empty() || !req.path.starts_with('/') {
        return Err((StatusCode::BAD_REQUEST, "path must start with /".into()));
    }
    if req.path.contains("/../") || req.path.ends_with("/..") || req.path.contains("%") {
        return Err((
            StatusCode::BAD_REQUEST,
            "path must not contain '..' or percent-encoding".into(),
        ));
    }
    if req.port == 0 {
        return Err((StatusCode::BAD_REQUEST, "port must be > 0".into()));
    }
    let mut store = ctx.services.lock().unwrap();
    if store.add_route(&name, req.path, req.port, req.strip) {
        Ok(StatusCode::CREATED)
    } else {
        Err((
            StatusCode::NOT_FOUND,
            format!("service '{}' not found", name),
        ))
    }
}

async fn remove_route(
    State(ctx): State<Arc<ServerCtx>>,
    Path(name): Path<String>,
    Json(req): Json<RemoveRouteRequest>,
) -> StatusCode {
    let mut store = ctx.services.lock().unwrap();
    if store.remove_route(&name, &req.path) {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
    let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
    Ok((
        [
            (header::CONTENT_TYPE, "application/x-pem-file"),
            (
                header::CONTENT_DISPOSITION,
                "attachment; filename=\"numa-ca.pem\"",
            ),
            (header::CACHE_CONTROL, "public, max-age=86400"),
        ],
        pem.to_string(),
    ))
}

async fn serve_qr(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
    if !ctx.mobile_enabled {
        return Err(StatusCode::NOT_FOUND);
    }
    let lan_ip = *ctx.lan_ip.lock().unwrap();
    let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port);
    let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let svg = code
        .render::<qrcode::render::svg::Color>()
        .min_dimensions(180, 180)
        .dark_color(qrcode::render::svg::Color("#2c2418"))
        .light_color(qrcode::render::svg::Color("#faf7f2"))
        .build();
    Ok((
        [
            (header::CONTENT_TYPE, "image/svg+xml"),
            (header::CACHE_CONTROL, "no-store"),
        ],
        svg,
    ))
}

async fn serve_locale(Path(file): Path<String>) -> Result<impl IntoResponse, StatusCode> {
    let stem = file.strip_suffix(".json").unwrap_or(&file);
    LOCALES
        .iter()
        .find(|(code, _)| *code == stem)
        .map(|(_, body)| {
            (
                [
                    (header::CONTENT_TYPE, "application/json; charset=utf-8"),
                    (header::CACHE_CONTROL, "no-cache"),
                ],
                *body,
            )
        })
        .ok_or(StatusCode::NOT_FOUND)
}

async fn serve_fonts_css() -> impl IntoResponse {
    (
        [
            (header::CONTENT_TYPE, "text/css"),
            (header::CACHE_CONTROL, "public, max-age=31536000"),
        ],
        FONTS_CSS,
    )
}

fn serve_font(data: &'static [u8]) -> impl IntoResponse {
    (
        [
            (header::CONTENT_TYPE, "font/woff2"),
            (header::CACHE_CONTROL, "public, max-age=31536000"),
        ],
        data,
    )
}

async fn check_tcp<A: tokio::net::ToSocketAddrs>(target: A) -> bool {
    tokio::time::timeout(
        std::time::Duration::from_millis(100),
        tokio::net::TcpStream::connect(target),
    )
    .await
    .map(|r| r.is_ok())
    .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use http::Request;
    use tower::ServiceExt;

    async fn test_ctx() -> Arc<ServerCtx> {
        Arc::new(crate::testutil::test_ctx().await)
    }

    async fn get_req(app: &Router, path: &str) -> http::Response<Body> {
        app.clone()
            .oneshot(Request::get(path).body(Body::empty()).unwrap())
            .await
            .unwrap()
    }

    async fn delete_req(app: &Router, path: &str) -> http::Response<Body> {
        app.clone()
            .oneshot(Request::delete(path).body(Body::empty()).unwrap())
            .await
            .unwrap()
    }

    async fn post_json(app: &Router, path: &str, body: &str) -> http::Response<Body> {
        app.clone()
            .oneshot(
                Request::post(path)
                    .header("content-type", "application/json")
                    .body(Body::from(body.to_string()))
                    .unwrap(),
            )
            .await
            .unwrap()
    }

    async fn body_json(resp: http::Response<Body>) -> serde_json::Value {
        let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
            .await
            .unwrap();
        serde_json::from_slice(&body).unwrap()
    }

    #[tokio::test]
    async fn health_returns_ok() {
        let ctx = test_ctx().await;
        let resp = router(ctx)
            .oneshot(Request::get("/health").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 200);
        let body = axum::body::to_bytes(resp.into_body(), 1000).await.unwrap();
        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(json["status"], "ok");
    }

    #[tokio::test]
    async fn stats_returns_json() {
        let ctx = test_ctx().await;
        let resp = router(ctx)
            .oneshot(Request::get("/stats").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 200);
        let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert!(json["uptime_secs"].is_number());
        assert!(json["queries"]["total"].is_number());
    }

    #[tokio::test]
    async fn query_log_empty() {
        let ctx = test_ctx().await;
        let resp = router(ctx)
            .oneshot(
                Request::get("/query-log?limit=10")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), 200);
        let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert!(json.as_array().unwrap().is_empty());
    }

    #[tokio::test]
    async fn overrides_crud() {
        let a = router(test_ctx().await);

        let resp = post_json(
            &a,
            "/overrides",
            r#"{"domain":"test.dev","target":"1.2.3.4","duration_secs":60}"#,
        )
        .await;
        assert!(resp.status().is_success());

        let body = axum::body::to_bytes(get_req(&a, "/overrides").await.into_body(), 10000)
            .await
            .unwrap();
        assert!(String::from_utf8_lossy(&body).contains("test.dev"));

        assert_eq!(get_req(&a, "/overrides/test.dev").await.status(), 200);
        assert!(delete_req(&a, "/overrides/test.dev")
            .await
            .status()
            .is_success());
        assert_eq!(get_req(&a, "/overrides/test.dev").await.status(), 404);
    }

    #[tokio::test]
    async fn cache_list_and_flush() {
        let a = router(test_ctx().await);
        assert_eq!(get_req(&a, "/cache").await.status(), 200);
        assert!(delete_req(&a, "/cache").await.status().is_success());
    }

    #[tokio::test]
    async fn blocking_stats_returns_json() {
        let ctx = test_ctx().await;
        let resp = router(ctx)
            .oneshot(Request::get("/blocking/stats").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 200);
        let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert!(json["enabled"].is_boolean());
    }

    #[tokio::test]
    async fn services_crud() {
        let a = router(test_ctx().await);

        let resp = post_json(&a, "/services", r#"{"name":"testapp","target_port":3000}"#).await;
        assert!(resp.status().is_success());

        let body = axum::body::to_bytes(get_req(&a, "/services").await.into_body(), 10000)
            .await
            .unwrap();
        assert!(String::from_utf8_lossy(&body).contains("testapp"));

        assert!(delete_req(&a, "/services/testapp")
            .await
            .status()
            .is_success());

        let body = axum::body::to_bytes(get_req(&a, "/services").await.into_body(), 10000)
            .await
            .unwrap();
        assert!(!String::from_utf8_lossy(&body).contains("testapp"));
    }

    #[tokio::test]
    async fn create_service_accepts_target_host() {
        let a = router(test_ctx().await);

        let resp = post_json(
            &a,
            "/services",
            r#"{"name":"nas","target_port":80,"target_host":"192.168.1.50"}"#,
        )
        .await;
        assert_eq!(resp.status(), StatusCode::CREATED);
        assert_eq!(body_json(resp).await["target_host"], "192.168.1.50");

        let list = body_json(get_req(&a, "/services").await).await;
        let entry = list
            .as_array()
            .unwrap()
            .iter()
            .find(|e| e["name"] == "nas")
            .expect("listed service");
        assert_eq!(entry["target_host"], "192.168.1.50");
    }

    #[tokio::test]
    async fn create_service_strips_default_target_host() {
        // "localhost", "127.0.0.1", and the empty/whitespace string all
        // collapse to the default (None) so listings stay clean.
        for (i, host) in ["localhost", "127.0.0.1", "", "   "].iter().enumerate() {
            let a = router(test_ctx().await);
            let body = format!(
                r#"{{"name":"app{}","target_port":3000,"target_host":"{}"}}"#,
                i, host
            );
            let resp = post_json(&a, "/services", &body).await;
            assert_eq!(resp.status(), StatusCode::CREATED, "host={:?}", host);
            let json = body_json(resp).await;
            assert!(
                json.get("target_host").map(|v| v.is_null()).unwrap_or(true),
                "host={:?} should collapse to default but got: {}",
                host,
                json
            );
        }
    }

    #[tokio::test]
    async fn create_service_rejects_overlong_target_host() {
        let a = router(test_ctx().await);
        let body = format!(
            r#"{{"name":"app","target_port":80,"target_host":"{}"}}"#,
            "a".repeat(254)
        );
        let resp = post_json(&a, "/services", &body).await;
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn create_service_rejects_target_host_with_scheme() {
        let a = router(test_ctx().await);
        let resp = post_json(
            &a,
            "/services",
            r#"{"name":"app","target_port":80,"target_host":"http://x.y"}"#,
        )
        .await;
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn service_url_uses_https_when_byo_cert_configured() {
        let mut bare = crate::testutil::test_ctx().await;
        bare.tls_byo = true;
        let a = router(Arc::new(bare));

        let resp = post_json(&a, "/services", r#"{"name":"nas","target_port":80}"#).await;
        assert_eq!(body_json(resp).await["url"], "https://nas.numa");
    }

    #[tokio::test]
    async fn dashboard_returns_html() {
        let ctx = test_ctx().await;
        let resp = router(ctx)
            .oneshot(Request::get("/").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(resp.status(), 200);
        assert_eq!(
            resp.headers()
                .get(header::CACHE_CONTROL)
                .map(|v| v.to_str().unwrap()),
            Some("no-cache"),
            "dashboard must revalidate to avoid stale HTML across upgrades"
        );
        let body = axum::body::to_bytes(resp.into_body(), 100000)
            .await
            .unwrap();
        assert!(String::from_utf8_lossy(&body).contains("Numa"));
    }

    #[tokio::test]
    async fn serves_english_locale_and_404s_unknown() {
        let app = router(test_ctx().await);
        let ok = get_req(&app, "/locales/en.json").await;
        assert_eq!(ok.status(), 200);
        let body = axum::body::to_bytes(ok.into_body(), 100_000).await.unwrap();
        let map: std::collections::BTreeMap<String, String> =
            serde_json::from_slice(&body).expect("served en.json parses");
        assert_eq!(
            map.get("panel.blocking").map(String::as_str),
            Some("Blocking")
        );

        assert_eq!(get_req(&app, "/locales/manifest.json").await.status(), 200);
        assert_eq!(get_req(&app, "/locales/nope.json").await.status(), 404);
    }

    fn locale(stem: &str) -> std::collections::BTreeMap<String, String> {
        let (_, body) = LOCALES
            .iter()
            .find(|(c, _)| *c == stem)
            .expect("locale present");
        serde_json::from_str(body).expect("locale is a flat string map")
    }

    #[test]
    fn every_locale_is_a_subset_of_english() {
        let en = locale("en");
        for (code, body) in LOCALES {
            if *code == "en" || *code == "manifest" {
                continue;
            }
            let m: std::collections::BTreeMap<String, String> =
                serde_json::from_str(body).unwrap_or_else(|e| panic!("{code}.json invalid: {e}"));
            for key in m.keys() {
                assert!(
                    en.contains_key(key),
                    "{code}.json has key '{key}' absent from en.json"
                );
            }
        }
    }

    #[test]
    fn manifest_lists_english_and_only_present_locales() {
        #[derive(serde::Deserialize)]
        struct Entry {
            code: String,
            native_name: String,
        }
        let (_, body) = LOCALES
            .iter()
            .find(|(c, _)| *c == "manifest")
            .expect("manifest present");
        let entries: Vec<Entry> = serde_json::from_str(body).expect("manifest is a JSON array");
        assert!(
            entries.iter().any(|e| e.code == "en"),
            "manifest must list en"
        );
        for e in &entries {
            assert!(!e.native_name.is_empty(), "{} missing native_name", e.code);
            assert!(
                LOCALES.iter().any(|(c, _)| c == &e.code),
                "manifest code '{}' has no locale file",
                e.code
            );
        }
    }
}
