//! Build-time operation-registry codegen (D9/D17/D22).
//!
//! Merges the two committed OpenAPI snapshots (`openapi/exa-openapi.json`,
//! `openapi/team-management.json`) with the curated `openapi/overlay.toml` into a
//! `static REGISTRY: &[OperationDef]` emitted to `$OUT_DIR/registry.rs`. The output is
//! byte-reproducible from the committed inputs (operations sorted by `(namespace, cli_path)`),
//! which is what makes the `registry_codegen_reproducible` gate meaningful rather than flaky.
//!
//! No YAML parser and no async runtime ship in the binary (D14/D21); this build script reads
//! the already-normalized JSON.

use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::PathBuf;

use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};

const EXA_SPEC: &str = "openapi/exa-openapi.json";
const ADMIN_SPEC: &str = "openapi/team-management.json";
const OVERLAY: &str = "openapi/overlay.toml";

#[derive(Deserialize)]
struct Overlay {
    operations: BTreeMap<String, OpMeta>,
}

#[derive(Deserialize)]
struct OpMeta {
    cli_path: String,
    namespace: String,
    #[serde(default)]
    confirm: Option<ConfirmMeta>,
    #[serde(default)]
    idempotency_sensitive: bool,
    #[serde(default)]
    dangerous: bool,
    #[serde(default)]
    streaming: bool,
    #[serde(default)]
    cursor: Option<String>,
    #[serde(default)]
    defined_by_overlay: bool,
    #[serde(default)]
    method: Option<String>,
    #[serde(default)]
    api_path: Option<String>,
    #[serde(default)]
    source: Option<String>,
    #[serde(default)]
    source_version: Option<String>,
    #[serde(default)]
    secret_capture: Option<SecretCaptureMeta>,
    #[serde(default)]
    mixed_status_exit: Option<bool>,
    #[serde(default)]
    body_builder: Option<String>,
    #[serde(default)]
    fields: Vec<FieldMeta>,
}

#[derive(Clone, Deserialize)]
struct SecretCaptureMeta {
    response_field: String,
    output_flag: String,
    #[serde(default)]
    required: bool,
}

#[derive(Clone, Deserialize)]
#[serde(untagged)]
enum ConfirmMeta {
    Simple(ConfirmKind),
    YesPlusEcho { yes_plus_echo: String },
}

#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ConfirmKind {
    Yes,
    EchoId,
}

/// Overlay extension shapes:
/// - field `co_fields = [["trigger.type", "interval"], ["trigger.active", true]]`
/// - field `item_template = "description"`
/// - op `body_builder = "SomeRealBuilder"` (unknown/test-only names fail the build)
#[derive(Clone, Deserialize)]
struct FieldMeta {
    flag: String,
    body_path: String,
    kind: String,
    #[serde(default)]
    required: bool,
    #[serde(default)]
    co_fields: Vec<(String, ConstMeta)>,
    #[serde(default)]
    item_template: Option<String>,
    #[serde(default)]
    enum_values: Vec<String>,
    #[serde(default)]
    range: Option<[f64; 2]>,
}

#[derive(Clone, Deserialize)]
#[serde(untagged)]
enum ConstMeta {
    Str(String),
    Bool(bool),
    Int(i64),
}

/// What a spec contributes for one operationId.
struct SpecOp {
    method: String,
    api_path: String,
    deprecated: bool,
}

struct SpecInfo {
    title: String,
    version: String,
    ops: BTreeMap<String, SpecOp>,
}

fn main() -> Result<()> {
    let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
    let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);

    for input in ["build.rs", EXA_SPEC, ADMIN_SPEC, OVERLAY] {
        println!("cargo:rerun-if-changed={input}");
    }
    println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
    // Re-bake GIT_SHA when HEAD moves. Without this, cargo only re-runs build.rs
    // when a spec input changes, so the baked SHA freezes on commits that don't
    // touch a spec — and the binary reports a commit it wasn't built from. Absent
    // in a published tarball (no .git), where git_sha() falls back to "unknown".
    let git_head = manifest.join(".git/HEAD");
    println!("cargo:rerun-if-changed={}", git_head.display());
    if let Ok(head) = std::fs::read_to_string(&git_head) {
        if let Some(reference) = head.strip_prefix("ref: ") {
            println!(
                "cargo:rerun-if-changed={}",
                manifest.join(".git").join(reference.trim()).display()
            );
        }
    }

    let exa_bytes = std::fs::read(manifest.join(EXA_SPEC)).context("read exa-openapi.json")?;
    let admin_bytes =
        std::fs::read(manifest.join(ADMIN_SPEC)).context("read team-management.json")?;
    let overlay_text =
        std::fs::read_to_string(manifest.join(OVERLAY)).context("read overlay.toml")?;

    let exa = parse_spec(&exa_bytes).context("parse exa spec")?;
    let admin = parse_spec(&admin_bytes).context("parse admin spec")?;
    let overlay: Overlay = toml::from_str(&overlay_text).context("parse overlay.toml")?;

    // The embedded-spec SHA-256 is computed over the exact public-spec bytes (arch §3).
    let spec_sha = hex(&Sha256::digest(&exa_bytes));

    let mut rows: Vec<OpRow> = Vec::new();
    for (oid, meta) in &overlay.operations {
        rows.push(resolve(oid, meta, &exa, &admin)?);
    }
    // Deterministic codegen: sort by (namespace, cli_path).
    rows.sort_by(|a, b| (a.namespace, &a.cli_path).cmp(&(b.namespace, &b.cli_path)));

    let mut out = String::new();
    out.push_str("// @generated by build.rs — do not edit. Regenerate via `cargo build`.\n\n");

    writeln!(out, "pub const EMBEDDED_SPEC_SHA256: &str = {spec_sha:?};")?;
    writeln!(out, "pub const SPEC_TITLE: &str = {:?};", exa.title)?;
    writeln!(out, "pub const SPEC_VERSION: &str = {:?};", exa.version)?;
    writeln!(
        out,
        "pub const SPEC_URL: &str = \"https://exa.ai/docs/exa-spec.json\";"
    )?;
    writeln!(out, "pub const ADMIN_SPEC_TITLE: &str = {:?};", admin.title)?;
    writeln!(
        out,
        "pub const ADMIN_SPEC_VERSION: &str = {:?};",
        admin.version
    )?;
    writeln!(out, "pub const GIT_SHA: &str = {:?};", git_sha())?;
    writeln!(out, "pub const BUILD_DATE: &str = {:?};", build_date())?;
    writeln!(
        out,
        "pub const TARGET: &str = {:?};",
        std::env::var("TARGET").unwrap_or_default()
    )?;
    out.push('\n');

    writeln!(out, "pub static REGISTRY: &[OperationDef] = &[")?;
    for r in &rows {
        emit_op(&mut out, r)?;
    }
    writeln!(out, "];")?;

    std::fs::write(out_dir.join("registry.rs"), out).context("write registry.rs")?;
    Ok(())
}

struct OpRow {
    cli_path: Vec<String>,
    operation_id: String,
    method: String,
    api_path: String,
    read_only: bool,
    streaming: bool,
    cursor: Option<String>,
    dangerous: bool,
    namespace: &'static str,
    confirm: Option<ConfirmMeta>,
    secret_capture: Option<SecretCaptureMeta>,
    mixed_status_exit: bool,
    idempotency_sensitive: bool,
    deprecated: bool,
    source: String,
    source_version: String,
    fields: Vec<FieldMeta>,
    body_builder: Option<String>,
}

fn resolve(oid: &str, meta: &OpMeta, exa: &SpecInfo, admin: &SpecInfo) -> Result<OpRow> {
    let namespace = match meta.namespace.as_str() {
        "api" => "Api",
        "service" => "Service",
        other => return Err(anyhow!("op {oid}: unknown namespace {other:?}")),
    };

    let (method, api_path, deprecated, source, source_version) = if meta.defined_by_overlay {
        (
            meta.method
                .clone()
                .ok_or_else(|| anyhow!("overlay-defined op {oid} needs `method`"))?,
            meta.api_path
                .clone()
                .ok_or_else(|| anyhow!("overlay-defined op {oid} needs `api_path`"))?,
            false,
            meta.source.clone().unwrap_or_else(|| "overlay".into()),
            meta.source_version
                .clone()
                .unwrap_or_else(|| "overlay".into()),
        )
    } else {
        let (spec, default_source, default_version) = if namespace == "Service" {
            (admin, "team-management.json", admin.version.clone())
        } else {
            (exa, "exa-spec.json", exa.version.clone())
        };
        let spec_op = spec
            .ops
            .get(oid)
            .ok_or_else(|| anyhow!("op {oid} not found in its spec ({default_source})"))?;
        (
            spec_op.method.clone(),
            websets_runtime_path(&spec_op.api_path),
            spec_op.deprecated,
            default_source.to_string(),
            default_version,
        )
    };

    let read_only = method == "GET";
    for field in &meta.fields {
        validate_field(oid, field)?;
    }

    Ok(OpRow {
        cli_path: meta
            .cli_path
            .split_whitespace()
            .map(str::to_string)
            .collect(),
        operation_id: oid.to_string(),
        method,
        api_path,
        read_only,
        streaming: meta.streaming,
        cursor: meta.cursor.clone(),
        dangerous: meta.dangerous,
        namespace,
        confirm: meta.confirm.clone(),
        secret_capture: meta.secret_capture.clone(),
        mixed_status_exit: meta.mixed_status_exit.unwrap_or(false),
        idempotency_sensitive: meta.idempotency_sensitive,
        deprecated,
        source,
        source_version,
        fields: meta.fields.to_vec(),
        body_builder: meta.body_builder.clone(),
    })
}

fn websets_runtime_path(path: &str) -> String {
    // The vendored OpenAPI path keys are rooted at /v0/..., but the in-repo live docs
    // examples use the deployed Websets base /websets/v0 (for example the
    // "Base URL: https://api.exa.ai/websets/v0" guide plus /teams/me). Prefer the runtime route here
    // while keeping the embedded spec untouched.
    for resource in [
        "websets", "teams", "events", "imports", "webhooks", "monitors",
    ] {
        let prefix = format!("/v0/{resource}");
        if let Some(rest) = path.strip_prefix(&prefix) {
            return format!("/websets/v0/{resource}{rest}");
        }
    }
    path.to_string()
}

fn validate_field(oid: &str, field: &FieldMeta) -> Result<()> {
    let who = || format!("op {oid} field --{}", field.flag);

    if let Some(item_template) = &field.item_template {
        if field.kind != "str_array" {
            return Err(anyhow!("{}: item_template only valid on str_array", who()));
        }
        if item_template.is_empty() {
            return Err(anyhow!("{}: item_template cannot be empty", who()));
        }
    }

    if !field.enum_values.is_empty() && field.kind != "str" {
        return Err(anyhow!("{}: enum_values only valid on str", who()));
    }
    if field.enum_values.iter().any(String::is_empty) {
        return Err(anyhow!("{}: enum_values entries cannot be empty", who()));
    }

    if let Some([min, max]) = field.range {
        if !matches!(field.kind.as_str(), "int" | "num") {
            return Err(anyhow!("{}: range only valid on int or num", who()));
        }
        if !min.is_finite() || !max.is_finite() {
            return Err(anyhow!("{}: range bounds must be finite", who()));
        }
        if min > max {
            return Err(anyhow!("{}: range min must be <= max", who()));
        }
    }

    if field.co_fields.iter().any(|(path, _)| path.is_empty()) {
        return Err(anyhow!("{}: co_fields paths cannot be empty", who()));
    }

    Ok(())
}

fn emit_op(out: &mut String, r: &OpRow) -> Result<()> {
    let cli_path = r
        .cli_path
        .iter()
        .map(|s| format!("{s:?}"))
        .collect::<Vec<_>>()
        .join(", ");
    let method = method_variant(&r.method)?;
    let pagination = match &r.cursor {
        Some(field) => format!("Pagination::Cursor({field:?})"),
        None => "Pagination::None".to_string(),
    };
    let mut fields = String::new();
    for f in &r.fields {
        let co_fields = co_fields_literal(&f.co_fields);
        let item_template = match &f.item_template {
            Some(key) => format!("Some({key:?})"),
            None => "None".to_string(),
        };
        let enum_values = enum_values_literal(&f.enum_values);
        let range = range_literal(f.range)?;
        write!(
            fields,
            "FieldDef {{ flag: {:?}, body_path: {:?}, kind: {}, required: {}, \
             co_fields: {co_fields}, item_template: {item_template}, enum_values: {enum_values}, range: {range} }}, ",
            f.flag,
            f.body_path,
            field_kind_variant(&f.kind)?,
            f.required
        )?;
    }
    let capabilities = capabilities(r)?;
    let body_builder = match &r.body_builder {
        Some(name) => format!("Some({})", builder_variant(&r.operation_id, name)?),
        None => "None".to_string(),
    };
    writeln!(
        out,
        "    OperationDef {{ cli_path: &[{cli_path}], operation_id: {oid:?}, method: {method}, \
         api_path: {api_path:?}, read_only: {read_only}, streaming: {streaming}, pagination: {pagination}, \
         dangerous: {dangerous}, namespace: Namespace::{ns}, idempotency_sensitive: {idem}, \
         deprecated: {dep}, source: {source:?}, source_version: {sver:?}, fields: &[{fields}], \
         capabilities: &[{capabilities}], body_builder: {body_builder}, validators: &[], mixed_status_exit: {mixed} }},",
        oid = r.operation_id,
        api_path = r.api_path,
        read_only = r.read_only,
        streaming = r.streaming,
        dangerous = r.dangerous,
        mixed = r.mixed_status_exit,
        ns = r.namespace,
        idem = r.idempotency_sensitive,
        dep = r.deprecated,
        source = r.source,
        sver = r.source_version,
    )?;
    Ok(())
}

fn co_fields_literal(fields: &[(String, ConstMeta)]) -> String {
    if fields.is_empty() {
        return "&[]".to_string();
    }

    let mut out = String::from("&[");
    for (path, value) in fields {
        let _ = write!(out, "({path:?}, {}), ", const_value_literal(value));
    }
    out.push(']');
    out
}

fn enum_values_literal(values: &[String]) -> String {
    if values.is_empty() {
        return "&[]".to_string();
    }

    let mut out = String::from("&[");
    for value in values {
        let _ = write!(out, "{value:?}, ");
    }
    out.push(']');
    out
}

fn range_literal(range: Option<[f64; 2]>) -> Result<String> {
    let Some([min, max]) = range else {
        return Ok("None".to_string());
    };
    if !min.is_finite() || !max.is_finite() {
        return Err(anyhow!("field range bounds must be finite"));
    }
    Ok(format!(
        "Some(({}, {}))",
        f64_literal(min),
        f64_literal(max)
    ))
}

fn f64_literal(value: f64) -> String {
    if value.fract() == 0.0 {
        format!("{value:.0}f64")
    } else {
        format!("{value:?}f64")
    }
}

fn const_value_literal(value: &ConstMeta) -> String {
    match value {
        ConstMeta::Str(s) => format!("ConstValue::Str({s:?})"),
        ConstMeta::Bool(b) => format!("ConstValue::Bool({b})"),
        ConstMeta::Int(i) => format!("ConstValue::Int({i})"),
    }
}

fn capabilities(r: &OpRow) -> Result<String> {
    let mut out = String::new();
    if let Some(confirm) = &r.confirm {
        let protocol = match confirm {
            ConfirmMeta::Simple(ConfirmKind::Yes) => "ConfirmProtocol::Yes".to_string(),
            ConfirmMeta::Simple(ConfirmKind::EchoId) => "ConfirmProtocol::EchoId".to_string(),
            ConfirmMeta::YesPlusEcho { yes_plus_echo } => {
                if yes_plus_echo.is_empty() {
                    return Err(anyhow!(
                        "op {}: confirm yes_plus_echo token cannot be empty",
                        r.operation_id
                    ));
                }
                format!("ConfirmProtocol::YesPlusEcho({yes_plus_echo:?})")
            }
        };
        write!(out, "Capability::Confirm({protocol}), ")?;
    }
    if let Some(sc) = &r.secret_capture {
        if sc.response_field.is_empty() || sc.output_flag.is_empty() {
            return Err(anyhow!(
                "op {}: secret_capture response_field/output_flag cannot be empty",
                r.operation_id
            ));
        }
        write!(
            out,
            "Capability::SecretCapture {{ response_field: {:?}, output_flag: {:?}, required: {} }}, ",
            sc.response_field, sc.output_flag, sc.required
        )?;
    }
    Ok(out)
}

fn method_variant(m: &str) -> Result<&'static str> {
    Ok(match m {
        "GET" => "Method::Get",
        "POST" => "Method::Post",
        "PUT" => "Method::Put",
        "PATCH" => "Method::Patch",
        "DELETE" => "Method::Delete",
        other => return Err(anyhow!("unknown HTTP method {other:?}")),
    })
}

fn field_kind_variant(k: &str) -> Result<&'static str> {
    Ok(match k {
        "str" => "FieldKind::Str",
        "int" => "FieldKind::Int",
        "bool" => "FieldKind::Bool",
        "num" => "FieldKind::Num",
        "str_array" => "FieldKind::StrArray",
        "json" => "FieldKind::Json",
        other => return Err(anyhow!("unknown field kind {other:?}")),
    })
}

fn builder_variant(oid: &str, name: &str) -> Result<&'static str> {
    match name {
        "Sentinel" => Err(anyhow!(
            "op {oid}: body_builder \"Sentinel\" is a test-only sentinel and cannot be assigned to an operation"
        )),
        other => Err(anyhow!("op {oid}: unknown body_builder {other:?}")),
    }
}

fn parse_spec(bytes: &[u8]) -> Result<SpecInfo> {
    let v: serde_json::Value = serde_json::from_slice(bytes)?;
    let info = v.get("info").ok_or_else(|| anyhow!("spec missing info"))?;
    let title = info
        .get("title")
        .and_then(|x| x.as_str())
        .unwrap_or_default()
        .to_string();
    let version = info
        .get("version")
        .and_then(|x| x.as_str())
        .unwrap_or_default()
        .to_string();
    let mut ops = BTreeMap::new();
    let paths = v
        .get("paths")
        .and_then(|p| p.as_object())
        .ok_or_else(|| anyhow!("spec missing paths"))?;
    for (path, methods) in paths {
        let Some(methods) = methods.as_object() else {
            continue;
        };
        for (method, op) in methods {
            let m = method.to_uppercase();
            if !matches!(m.as_str(), "GET" | "POST" | "PUT" | "PATCH" | "DELETE") {
                continue;
            }
            let Some(oid) = op.get("operationId").and_then(|x| x.as_str()) else {
                continue;
            };
            ops.insert(
                oid.to_string(),
                SpecOp {
                    method: m,
                    api_path: path.clone(),
                    deprecated: op
                        .get("deprecated")
                        .and_then(|x| x.as_bool())
                        .unwrap_or(false),
                },
            );
        }
    }
    Ok(SpecInfo {
        title,
        version,
        ops,
    })
}

fn hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// Best-effort short git SHA; "unknown" off a checkout (keeps offline builds working).
fn git_sha() -> String {
    std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "unknown".to_string())
}

/// Reproducible-build-friendly: no wall-clock. Prefers `SOURCE_DATE_EPOCH` (the standard
/// reproducible-builds override), falls back to the HEAD commit's timestamp so a plain
/// `cargo install` still gets a real date, and gives up to "unknown" outside a checkout.
fn build_date() -> String {
    let epoch = std::env::var("SOURCE_DATE_EPOCH")
        .ok()
        .and_then(|s| s.trim().parse::<i64>().ok())
        .or_else(git_commit_epoch);
    match epoch {
        Some(epoch) => epoch_to_iso_date(epoch),
        None => "unknown".to_string(),
    }
}

/// Unix timestamp (seconds) of the git HEAD commit; `None` off a checkout.
fn git_commit_epoch() -> Option<i64> {
    std::process::Command::new("git")
        .args(["log", "-1", "--format=%ct"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .and_then(|s| s.trim().parse::<i64>().ok())
}

/// Format a Unix timestamp as an ISO-8601 UTC date (`YYYY-MM-DD`), no external date crate.
/// Uses Howard Hinnant's `civil_from_days` algorithm (public domain).
fn epoch_to_iso_date(epoch_secs: i64) -> String {
    let days = epoch_secs.div_euclid(86_400);
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{y:04}-{m:02}-{d:02}")
}
