//! Per-Worker `index.ts` generation (v0.8 §4.3, v0.9 §5.1).
//!
//! Each Worker exports a `default { fetch }` handler that first checks
//! for the internal Service Binding prefix (`/_bynk/call/`) and dispatches
//! to the matching `on call` service operation, then matches the request
//! method + path against any `on http` routes the context declares.
//! Validation and uncaught errors map to 400 / 500 respectively.

use std::fmt::Write as _;

use crate::emitter::http_handler_method_name;
use crate::project::UnitTable;
use bynk_syntax::ast::*;

pub fn emit_worker_entry(context: &str, table: &UnitTable) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "// Generated by bynkc — do not edit by hand.");
    let _ = writeln!(out, "// Worker entry point for context `{context}`.");
    writeln!(out).unwrap();

    // v0.79: if any handler uses `~>`, each entry point captures the runtime's
    // execution context and threads it into `compose`, so a fire-and-forget send
    // can hand its promise to `waitUntil`. Otherwise the signatures are unchanged.
    let ctx_uses_send = table.services.values().any(|s| {
        s.handlers
            .iter()
            .any(|h| crate::emitter::block_uses_send(&h.body))
    });
    let exec_param = if ctx_uses_send {
        ", ctx: { waitUntil(promise: Promise<unknown>): void }"
    } else {
        ""
    };
    let compose_call = if ctx_uses_send {
        "compose(env, ctx)"
    } else {
        "compose(env)"
    };

    // Collect HTTP routes across all services, sorted so literal-segment
    // routes precede parameter-segment routes that share the same prefix.
    let mut http_routes: Vec<HttpRoute> = Vec::new();
    let mut service_names: Vec<&String> = table.services.keys().collect();
    service_names.sort();
    for sname in &service_names {
        let service = table.services.get(*sname).unwrap();
        for h in &service.handlers {
            if let HandlerKind::Http { method, path } = &h.kind {
                http_routes.push(HttpRoute {
                    service: (*sname).clone(),
                    method: *method,
                    path: path.clone(),
                    handler: h.clone(),
                    // v0.47: a Bearer handler's surface wrapper runs the
                    // verification seam and needs the request passed in.
                    bearer: bynk_check::actors::bearer_seam_for(h, &table.actors).is_some(),
                    signature: bynk_check::actors::signature_seam_for(h, &table.actors),
                    // v0.52: a multi-actor sum handler's wrapper owns the whole
                    // boundary (raw read, first-wins resolution, body parse), so
                    // the entry just passes `request` and skips the body parse.
                    sum: bynk_check::actors::sum_members_for(h, &table.actors).is_some(),
                    // v0.140 (ADR 0163): the handler's `@cache` freshness policy, if
                    // any — lowered to a `Cache-Control` on this GET's responses.
                    cache: cache_policy_for(h),
                });
            }
        }
    }
    http_routes.sort_by(|a, b| {
        // Sort by literal-specificity: a path with fewer parameter segments
        // (more literals) wins. Stable secondary sort by method + path keeps
        // the diff deterministic.
        param_count(&a.path)
            .cmp(&param_count(&b.path))
            .then_with(|| a.method.as_str().cmp(b.method.as_str()))
            .then_with(|| a.path.cmp(&b.path))
    });

    // v0.131 (ADR 0159): the per-service CORS policies. A `from http` service
    // with a `cors { }` section gets one synthesised `CorsPolicy` constant; its
    // routes are answered with a preflight `OPTIONS` branch and stamped with the
    // `Access-Control-*` headers. Allow-methods is derived from the service's
    // routes (never restated); allow-headers defaults to `content-type` (plus
    // `Authorization` when the service has a Bearer route) unless overridden.
    let cors_services: Vec<CorsService> = build_cors_services(&service_names, table, &http_routes);

    // v0.141 (ADR 0164): the per-service security-headers policies. Unlike CORS,
    // *every* `from http` service with routes gets one — the safe header
    // (`nosniff`) is on by default, so a service with no `security { }` still
    // stamps the defaults. Its responses (and the synthesised preflight / `405` /
    // `304`) carry `applySecurityHeaders`, composing with `applyCors`.
    let security_services: Vec<SecurityService> =
        build_security_services(&service_names, table, &http_routes);

    // v0.139 (ADR 0162): the per-path method table driving the method-aware
    // router fall-through — a wrong method to a live path is a `405 + Allow`, a
    // bare `OPTIONS` is a `204 + Allow`, and both are derived from this one table
    // (the same derivation CORS reads for its allow-methods).
    let path_methods: Vec<PathMethods> =
        build_path_method_table(&http_routes, &cors_services, &security_services);
    // A context with a `GET` route synthesises `HEAD` answers, which strip the
    // built `Response` body through the runtime's `headResponse`.
    let has_get_route = http_routes.iter().any(|r| r.method == HttpMethod::Get);

    // v0.10a: collect cron handlers across all services, carrying the per-service
    // declaration index (the method-name key) and sorting by schedule expression
    // for deterministic switch output.
    let mut cron_routes: Vec<CronRoute> = Vec::new();
    for sname in &service_names {
        let service = table.services.get(*sname).unwrap();
        let mut cron_idx = 0usize;
        for h in &service.handlers {
            if let HandlerKind::Cron { expr } = &h.kind {
                cron_routes.push(CronRoute {
                    service: (*sname).clone(),
                    index: cron_idx,
                    expr: expr.clone(),
                    has_param: !h.params.is_empty(),
                });
                cron_idx += 1;
            }
        }
    }
    cron_routes.sort_by(|a, b| a.expr.cmp(&b.expr));

    // v0.10b: collect queue consumers across all services, carrying the
    // per-service declaration index and sorting by queue name.
    let mut queue_routes: Vec<QueueRoute> = Vec::new();
    for sname in &service_names {
        let service = table.services.get(*sname).unwrap();
        let mut queue_idx = 0usize;
        for h in &service.handlers {
            if let HandlerKind::Message = &h.kind {
                // v0.44: the bound queue name lives on the service header
                // (`from queue("name")`), not on the handler.
                let ServiceProtocol::Queue { name } = &service.protocol else {
                    continue;
                };
                let msg_type = h.params.first().map(|p| p.type_ref.clone());
                queue_routes.push(QueueRoute {
                    service: (*sname).clone(),
                    index: queue_idx,
                    name: name.clone(),
                    msg_type,
                });
                queue_idx += 1;
            }
        }
    }
    queue_routes.sort_by(|a, b| a.name.cmp(&b.name));

    // v0.104 (real-time track slice 3b): the `from WebSocket` upgrade routes. An
    // `Upgrade: websocket` request dispatches to the service's edge wrapper
    // (`ws_<service>_open`), which authenticates and forwards to the hosting DO.
    // Route params come from the upgrade URL's query string (the v1 convention).
    let mut ws_open_routes: Vec<(&String, &Handler)> = Vec::new();
    for sname in &service_names {
        let service = table.services.get(*sname).unwrap();
        for h in &service.handlers {
            if matches!(h.kind, HandlerKind::Open) {
                ws_open_routes.push((*sname, h));
            }
        }
    }

    let has_http = !http_routes.is_empty();

    let mut imports: Vec<&str> = vec![
        "Ok",
        "Err",
        "type Result",
        "type JsonValue",
        "type BoundaryError",
        "boundaryError",
    ];
    if has_http {
        imports.push("matchPath");
        imports.push("httpResultToResponse");
    }
    // v0.139: a context that answers `HEAD` (any `GET` route) strips the built
    // response body through `headResponse`.
    if has_get_route {
        imports.push("headResponse");
        // v0.140 (ADR 0163): every `GET` carries a weak `ETag` and is answered
        // `304` on a matching `If-None-Match` via `notModifiedIfMatch`.
        imports.push("notModifiedIfMatch");
    }
    // v0.140 (ADR 0163): a `GET` carrying `@cache` stamps `Cache-Control` through
    // `applyCache` — imported only when some route declares one.
    if http_routes.iter().any(|r| r.cache.is_some()) {
        imports.push("applyCache");
    }
    // v0.131: a context with a CORS-enabled service imports the CORS helpers.
    if !cors_services.is_empty() {
        imports.push("type CorsPolicy");
        imports.push("applyCors");
        imports.push("corsPreflightResponse");
    }
    // v0.141: a context with any `from http` service imports the security-header
    // helper — every such service stamps at least the default `nosniff`.
    if !security_services.is_empty() {
        imports.push("type SecurityPolicy");
        imports.push("applySecurityHeaders");
    }
    // v0.51: a context with a Signature handler imports the HMAC verifier.
    if http_routes.iter().any(|r| r.signature.is_some()) {
        imports.push("verifySignatureHmacSha256");
    }
    let _ = writeln!(
        out,
        "import {{ {} }} from \"../../runtime.js\";",
        imports.join(", ")
    );
    let _ = writeln!(out, "import {{ compose, type Env }} from \"./compose.js\";");
    let _ = writeln!(out, "import * as handlers from \"./handlers.js\";");
    writeln!(out).unwrap();

    // v0.9.2: re-export each agent's Durable Object class from the Worker
    // entry. Cloudflare resolves a `class_name` binding against the named
    // exports of the Worker's `main`, so the DO classes declared in
    // `handlers.ts` must be visible here.
    let mut agent_names: Vec<&String> = table.agents.keys().collect();
    agent_names.sort();
    if !agent_names.is_empty() {
        let joined = agent_names
            .iter()
            .map(|n| n.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        let _ = writeln!(out, "export {{ {joined} }} from \"./handlers.js\";");
        writeln!(out).unwrap();
    }

    let _ = writeln!(out, "export default {{");
    let _ = writeln!(
        out,
        "  async fetch(request: Request, env: Env{exec_param}): Promise<Response> {{"
    );
    let _ = writeln!(out, "    const url = new URL(request.url);");
    let _ = writeln!(out, "    const path = url.pathname;");
    let _ = writeln!(out, "    const method = request.method;");
    let _ = writeln!(out, "    const surface = {compose_call};");
    // v0.131: the synthesised CORS policy constants, one per CORS-enabled service.
    for cs in &cors_services {
        let _ = writeln!(
            out,
            "    const {}: CorsPolicy = {};",
            cs.const_name, cs.literal
        );
    }
    // v0.141: the synthesised security-headers policy constants, one per
    // `from http` service (with routes) — the default `nosniff` and any opt-in HSTS.
    for ss in &security_services {
        let _ = writeln!(
            out,
            "    const {}: SecurityPolicy = {};",
            ss.const_name, ss.literal
        );
    }
    let _ = writeln!(out, "    try {{");

    // 1. Internal Service Binding dispatch.
    let _ = writeln!(out, "      if (path.startsWith(\"/_bynk/call/\")) {{");
    let _ = writeln!(
        out,
        "        const servicePath = path.slice(\"/_bynk/call/\".length);"
    );
    let _ = writeln!(out, "        switch (servicePath) {{");
    for sname in &service_names {
        let service = table.services.get(*sname).unwrap();
        let Some(h) = service
            .handlers
            .iter()
            .find(|h| matches!(h.kind, HandlerKind::Call))
        else {
            continue;
        };

        let _ = writeln!(out, "          case \"{sname}\": {{");
        let _ = writeln!(
            out,
            "            const args = await request.json() as JsonValue;"
        );
        emit_call_handler_dispatch(&mut out, sname, h, &table.actors);
        let _ = writeln!(out, "          }}");
    }
    let _ = writeln!(out, "          default:");
    let _ = writeln!(
        out,
        "            return new Response(\"Not found\", {{ status: 404 }});"
    );
    let _ = writeln!(out, "        }}");
    let _ = writeln!(out, "      }}");
    writeln!(out).unwrap();

    // 1.5. WebSocket upgrade dispatch (v0.104, slice 3b). An `Upgrade: websocket`
    // request routes to the `from WebSocket` service's edge wrapper, which runs the
    // fail-closed auth seam and forwards to the hosting Durable Object. Route params
    // are read from the upgrade URL's query string by name (the v1 convention; a
    // missing required param is a `400`).
    if !ws_open_routes.is_empty() {
        let _ = writeln!(
            out,
            "      if (request.headers.get(\"Upgrade\") === \"websocket\") {{"
        );
        for (sname, h) in &ws_open_routes {
            let mut args: Vec<String> = vec!["request".to_string()];
            for p in &h.params {
                let pn = &p.name.name;
                let _ = writeln!(
                    out,
                    "        const __ws_{pn} = url.searchParams.get(\"{pn}\");"
                );
                let _ = writeln!(
                    out,
                    "        if (__ws_{pn} === null) return new Response(\"Missing parameter: {pn}\", {{ status: 400 }});"
                );
                args.push(format!("__ws_{pn}"));
            }
            let _ = writeln!(
                out,
                "        return surface.ws_{sname}_open({});",
                args.join(", ")
            );
        }
        let _ = writeln!(out, "      }}");
        writeln!(out).unwrap();
    }

    // v0.131 (ADR 0159): CORS preflight. An `OPTIONS` against any route path of a
    // CORS-enabled service is answered here — before the route dispatch and its
    // auth seam, since a preflight is credential-less by spec and must not be
    // rejected by a `by` actor / Bearer check.
    //
    // v0.139 (ADR 0162 D4): a *real* preflight is distinguished from a bare
    // discovery `OPTIONS` by the `Access-Control-Request-Method` header. A
    // preflight (has it) is answered here with the `Access-Control-*` grant; a
    // bare `OPTIONS` (lacks it) falls through to the generic `204 + Allow` below,
    // so the two `OPTIONS` answers compose instead of colliding.
    for cs in &cors_services {
        let cond = cs
            .paths
            .iter()
            .map(|(path, has_params)| {
                let lit = path.replace('"', "\\\"");
                if *has_params {
                    format!("matchPath(\"{lit}\", path) !== null")
                } else {
                    format!("path === \"{lit}\"")
                }
            })
            .collect::<Vec<_>>()
            .join(" || ");
        let _ = writeln!(
            out,
            "      if (method === \"OPTIONS\" && request.headers.get(\"access-control-request-method\") !== null && ({cond})) {{"
        );
        // v0.141 (ADR 0164 DECISION E): the synthesised preflight also carries the
        // service's security headers — a CORS-enabled service is a `from http`
        // service, so it always has a security policy constant.
        let preflight = format!(
            "corsPreflightResponse({}, request.headers.get(\"origin\"))",
            cs.const_name
        );
        let stamped = match security_services.iter().find(|s| s.service == cs.service) {
            Some(ss) => format!("applySecurityHeaders({preflight}, {})", ss.const_name),
            None => preflight,
        };
        let _ = writeln!(out, "        return {stamped};");
        let _ = writeln!(out, "      }}");
    }

    // 2. External HTTP routes.
    for route in &http_routes {
        let cors_const = cors_services
            .iter()
            .find(|cs| cs.service == route.service)
            .map(|cs| cs.const_name.as_str());
        // v0.141: every `from http` route's service has a security policy constant.
        let security_const = security_services
            .iter()
            .find(|ss| ss.service == route.service)
            .map(|ss| ss.const_name.as_str());
        emit_http_route_dispatch(&mut out, route, cors_const, security_const);
    }

    // v0.139 (ADR 0162): the method-aware router fall-through. A request that
    // matched no dispatch block is tested against each known path; a match means
    // a live path reached under an unhandled method, so a bare `OPTIONS` is a
    // `204 + Allow` and any other method is a `405 + Allow` (the `Allow` derived
    // from the route table). For a CORS-enabled path the synthesised response is
    // stamped with `applyCors` (D5) so a cross-origin `405`/`OPTIONS` is not
    // invisible to the browser. No path matches ⇒ the `404` as before.
    for pm in &path_methods {
        let allow = pm.methods.join(", ");
        let match_cond = if pm.has_params {
            let lit = pm.path.replace('"', "\\\"");
            format!("matchPath(\"{lit}\", path) !== null")
        } else {
            let lit = pm.path.replace('"', "\\\"");
            format!("path === \"{lit}\"")
        };
        let _ = writeln!(out, "      if ({match_cond}) {{");
        // OPTIONS → 204, everything else → 405; both carry the derived Allow.
        let _ = writeln!(
            out,
            "        const __status = method === \"OPTIONS\" ? 204 : 405;"
        );
        let _ = writeln!(
            out,
            "        const __res = new Response(null, {{ status: __status, headers: {{ allow: \"{allow}\" }} }});"
        );
        // Stamp CORS (v0.131) then security headers (v0.141) — disjoint sets, so
        // the order is immaterial; every `from http` path carries the security
        // policy (DECISION E), CORS only when the path's service opts in.
        let cors_expr = match &pm.cors_const {
            Some(c) => format!("applyCors(__res, {c}, request.headers.get(\"origin\"))"),
            None => "__res".to_string(),
        };
        let stamped = match &pm.security_const {
            Some(s) => format!("applySecurityHeaders({cors_expr}, {s})"),
            None => cors_expr,
        };
        let _ = writeln!(out, "        return {stamped};");
        let _ = writeln!(out, "      }}");
    }

    let _ = writeln!(
        out,
        "      return new Response(\"Not Found\", {{ status: 404 }});"
    );
    let _ = writeln!(out, "    }} catch {{");
    let _ = writeln!(
        out,
        "      return new Response(\"Internal Server Error\", {{ status: 500 }});"
    );
    let _ = writeln!(out, "    }}");
    let _ = writeln!(out, "  }},");

    // v0.10a: scheduled (cron) entry point. Dispatches on `event.cron`. A
    // failing run has no retry channel, so an `Err` is logged and the run
    // completes (v0.10 §5.1, [DECISION 3]).
    if !cron_routes.is_empty() {
        emit_scheduled_handler(&mut out, &cron_routes, exec_param, compose_call);
    }

    // v0.10b: queue (consumer) entry point. Dispatches on `batch.queue`,
    // deserialises each message, invokes the handler, and acks on `Ok` /
    // retries on `Err` (a deserialisation failure also retries).
    if !queue_routes.is_empty() {
        emit_queue_handler(&mut out, &queue_routes, exec_param, compose_call);
    }

    let _ = writeln!(out, "}};");

    out
}

/// Emit the Worker `scheduled` handler aggregating every `on cron` handler in
/// the context. `event` is typed structurally (`{ cron: string }`) to avoid a
/// dependency on `@cloudflare/workers-types`, matching how the rest of the
/// emitter hand-declares the minimal ambient shapes it needs.
fn emit_scheduled_handler(
    out: &mut String,
    cron_routes: &[CronRoute],
    exec_param: &str,
    compose_call: &str,
) {
    let _ = writeln!(
        out,
        "  async scheduled(event: {{ readonly cron: string; readonly scheduledTime: number }}, env: Env{exec_param}): Promise<void> {{"
    );
    let _ = writeln!(out, "    const surface = {compose_call};");
    let _ = writeln!(out, "    switch (event.cron) {{");
    for route in cron_routes {
        let method_key = crate::emitter::cron_handler_method_name(&route.service, route.index);
        let expr_lit = route.expr.replace('\\', "\\\\").replace('"', "\\\"");
        // Pass the scheduled fire time (epoch ms) when the handler asked for it.
        let arg = if route.has_param {
            "event.scheduledTime"
        } else {
            ""
        };
        let _ = writeln!(out, "      case \"{expr_lit}\": {{");
        let _ = writeln!(
            out,
            "        const result = await surface.{method_key}({arg});"
        );
        let _ = writeln!(
            out,
            "        if (result.tag === \"Err\") console.error(\"cron {expr_lit} failed\", result.error);"
        );
        let _ = writeln!(out, "        return;");
        let _ = writeln!(out, "      }}");
    }
    let _ = writeln!(out, "      default:");
    let _ = writeln!(out, "        return;");
    let _ = writeln!(out, "    }}");
    let _ = writeln!(out, "  }},");
}

/// Emit the Worker `queue` handler aggregating every `on queue` consumer in the
/// context. Dispatches on `batch.queue`; for each message it deserialises the
/// body (v0.8 wire-format), invokes the handler, and acks on `Ok` / retries on
/// `Err`. A deserialisation failure or a thrown error also retries. `batch` is
/// typed structurally to avoid a `@cloudflare/workers-types` dependency.
fn emit_queue_handler(
    out: &mut String,
    queue_routes: &[QueueRoute],
    exec_param: &str,
    compose_call: &str,
) {
    let _ = writeln!(
        out,
        "  async queue(batch: {{ readonly queue: string; readonly messages: ReadonlyArray<{{ readonly body: unknown; ack(): void; retry(): void }}> }}, env: Env{exec_param}): Promise<void> {{"
    );
    let _ = writeln!(out, "    const surface = {compose_call};");
    let _ = writeln!(out, "    switch (batch.queue) {{");
    for route in queue_routes {
        let method_key = crate::emitter::queue_handler_method_name(&route.service, route.index);
        let name_lit = route.name.replace('\\', "\\\\").replace('"', "\\\"");
        let dser = match &route.msg_type {
            Some(t) => deserialise_call(t, "(msg.body as JsonValue)", "$"),
            None => "Ok(msg.body as any) as Result<any, BoundaryError>".to_string(),
        };
        let _ = writeln!(out, "      case \"{name_lit}\": {{");
        let _ = writeln!(out, "        for (const msg of batch.messages) {{");
        let _ = writeln!(out, "          try {{");
        let _ = writeln!(out, "            const __r = {dser};");
        let _ = writeln!(
            out,
            "            if (__r.tag === \"Err\") {{ console.error(\"queue {name_lit} deserialise failed\", __r.error); msg.retry(); continue; }}"
        );
        let _ = writeln!(
            out,
            "            const result = await surface.{method_key}(__r.value);"
        );
        let _ = writeln!(out, "            if (result.tag === \"Ack\") msg.ack();");
        let _ = writeln!(
            out,
            "            else {{ console.error(\"queue {name_lit} retry\", result.reason); msg.retry(); }}"
        );
        let _ = writeln!(out, "          }} catch (e) {{");
        let _ = writeln!(
            out,
            "            console.error(\"queue {name_lit} threw\", e); msg.retry();"
        );
        let _ = writeln!(out, "          }}");
        let _ = writeln!(out, "        }}");
        let _ = writeln!(out, "        return;");
        let _ = writeln!(out, "      }}");
    }
    let _ = writeln!(out, "      default:");
    let _ = writeln!(out, "        return;");
    let _ = writeln!(out, "    }}");
    let _ = writeln!(out, "  }},");
}

/// Count the number of `:param` segments in a path pattern.
fn param_count(path: &str) -> usize {
    path.split('/')
        .filter(|s| s.starts_with(':') && s.len() > 1)
        .count()
}

/// A CORS-enabled service and its synthesised policy (v0.131, ADR 0159). Carries
/// the emitted `CorsPolicy` object literal, the constant name it binds to, and
/// the service's distinct route paths (with a param flag) so the preflight branch
/// can match any of them.
struct CorsService {
    service: String,
    const_name: String,
    literal: String,
    paths: Vec<(String, bool)>,
}

/// Escape a string for a double-quoted TypeScript literal.
fn ts_str(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// A TypeScript array literal of strings.
fn ts_str_array(items: &[String]) -> String {
    let inner = items
        .iter()
        .map(|s| format!("\"{}\"", ts_str(s)))
        .collect::<Vec<_>>()
        .join(", ");
    format!("[{inner}]")
}

/// v0.139 (ADR 0162 D2): the one derivation of the methods a path answers,
/// shared by the CORS preflight's allow-methods, the `Allow` header on a
/// synthesised `405`/`OPTIONS`, and the `HEAD`-from-`GET` synthesis. It is the
/// union of the methods declared on the path, plus `HEAD` when `GET` is present,
/// plus `OPTIONS` always. The `BTreeSet` yields a stable alphabetical order
/// (`DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT`) — so `Allow: GET, HEAD,
/// OPTIONS` and `Allow: OPTIONS, POST` read as the proposal specifies.
fn derive_allowed_methods(methods: impl Iterator<Item = HttpMethod>) -> Vec<String> {
    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    let mut has_get = false;
    for m in methods {
        let s = m.as_str().to_string();
        if s == "GET" {
            has_get = true;
        }
        set.insert(s);
    }
    if has_get {
        set.insert("HEAD".to_string());
    }
    set.insert("OPTIONS".to_string());
    set.into_iter().collect()
}

/// v0.139 (ADR 0162): a distinct route path and the method set it answers, the
/// table the method-aware router fall-through reads. `cors_const` names the
/// owning CORS-enabled service's policy constant (if any) so a synthesised
/// `405`/`OPTIONS` for a cross-origin path is stamped with `applyCors` (D5).
struct PathMethods {
    path: String,
    has_params: bool,
    /// Alphabetical, e.g. `["GET", "HEAD", "OPTIONS"]`.
    methods: Vec<String>,
    cors_const: Option<String>,
    /// v0.141 (ADR 0164): the owning service's security policy constant, so a
    /// synthesised `405`/`OPTIONS` for the path is stamped with the same headers
    /// its real responses carry (DECISION E). `Some` for every `from http` path.
    security_const: Option<String>,
}

/// Build the per-path method table across *every* `from http` service in the
/// context (unlike `build_cors_services`, which is scoped to CORS-enabled
/// services). Paths are emitted in sorted order for a deterministic router.
fn build_path_method_table(
    http_routes: &[HttpRoute],
    cors_services: &[CorsService],
    security_services: &[SecurityService],
) -> Vec<PathMethods> {
    let mut paths: Vec<String> = Vec::new();
    for r in http_routes {
        if !paths.contains(&r.path) {
            paths.push(r.path.clone());
        }
    }
    paths.sort();
    paths
        .into_iter()
        .map(|path| {
            let methods = derive_allowed_methods(
                http_routes
                    .iter()
                    .filter(|r| r.path == path)
                    .map(|r| r.method),
            );
            let has_params = param_count(&path) > 0;
            // A path is expected unique across a context's HTTP surface (the same
            // note ADR 0159 makes); if two services declare it, the first CORS
            // owner wins the stamping.
            let cors_const = cors_services
                .iter()
                .find(|cs| cs.paths.iter().any(|(p, _)| *p == path))
                .map(|cs| cs.const_name.clone());
            // v0.141: the owning service's security constant — the first service
            // that declares this path (paths are expected unique, so this is the
            // owner). Every `from http` service has one.
            let security_const = http_routes
                .iter()
                .find(|r| r.path == path)
                .and_then(|r| security_services.iter().find(|ss| ss.service == r.service))
                .map(|ss| ss.const_name.clone());
            PathMethods {
                path,
                has_params,
                methods,
                cors_const,
                security_const,
            }
        })
        .collect()
}

/// Build the per-service CORS policies. A service with a `cors { }` section on a
/// `from http` protocol gets one `CorsService`; allow-methods is derived from its
/// routes (union + `OPTIONS`), and allow-headers defaults to `content-type` (plus
/// `Authorization` when any of its routes carries a Bearer seam) unless the author
/// overrode `headers:`.
fn build_cors_services(
    service_names: &[&String],
    table: &UnitTable,
    http_routes: &[HttpRoute],
) -> Vec<CorsService> {
    let mut out = Vec::new();
    for sname in service_names {
        let service = table.services.get(*sname).unwrap();
        let Some(policy) = &service.cors else {
            continue;
        };
        if !matches!(service.protocol, ServiceProtocol::Http) {
            continue;
        }
        let routes: Vec<&HttpRoute> = http_routes
            .iter()
            .filter(|r| &r.service == *sname)
            .collect();
        if routes.is_empty() {
            continue;
        }

        // Allow-methods: derived from the routes via the one shared rule
        // (v0.139, ADR 0162 D2) — the union of the service's route methods, plus
        // HEAD when GET is present, plus OPTIONS always, alphabetical. So a
        // CORS-enabled service that answers GET advertises HEAD in its
        // `Access-Control-Allow-Methods` too (a cross-origin HEAD is a real
        // request), from the same table that drives the `Allow` header.
        let methods = derive_allowed_methods(routes.iter().map(|r| r.method));

        // Allow-headers: the author's override, else content-type (+ Authorization
        // when the service has a Bearer route — the header the browser must be
        // allowed to send for it).
        let allow_headers = policy.allow_headers().unwrap_or_else(|| {
            let mut hs = vec!["content-type".to_string()];
            if routes.iter().any(|r| r.bearer) {
                hs.push("authorization".to_string());
            }
            hs
        });

        let max_age = match policy.max_age_secs() {
            Some(n) => n.to_string(),
            None => "null".to_string(),
        };
        let literal = format!(
            "{{ origins: {}, allowMethods: {}, allowHeaders: {}, credentials: {}, maxAgeSecs: {} }}",
            ts_str_array(&policy.origins()),
            ts_str_array(&methods),
            ts_str_array(&allow_headers),
            policy.credentials(),
            max_age,
        );

        // Distinct route paths for the preflight match.
        let mut paths: Vec<(String, bool)> = Vec::new();
        for r in &routes {
            let entry = (r.path.clone(), param_count(&r.path) > 0);
            if !paths.contains(&entry) {
                paths.push(entry);
            }
        }

        out.push(CorsService {
            service: (*sname).clone(),
            const_name: format!("__cors_{sname}"),
            literal,
            paths,
        });
    }
    out
}

/// v0.141 (ADR 0164): a `from http` service and its synthesised security-headers
/// policy constant. Unlike `CorsService`, one exists for *every* `from http`
/// service with routes (not only those with a `security { }` block) — the safe
/// header (`nosniff`) is on by default, so a blockless service still stamps the
/// defaults.
struct SecurityService {
    service: String,
    const_name: String,
    literal: String,
}

/// Build the per-service security policies. Every `from http` service with at
/// least one route gets a `__security_<service>` constant: the author's
/// `security { }` values, or the defaults (`nosniff: true`, no HSTS) when the
/// service declares no block. This is the one place the security lowering
/// diverges from `build_cors_services` (which skips a serviceless-of-a-block
/// service) — a default policy applies to every service (DECISION D).
fn build_security_services(
    service_names: &[&String],
    table: &UnitTable,
    http_routes: &[HttpRoute],
) -> Vec<SecurityService> {
    let mut out = Vec::new();
    for sname in service_names {
        let service = table.services.get(*sname).unwrap();
        if !matches!(service.protocol, ServiceProtocol::Http) {
            continue;
        }
        if !http_routes.iter().any(|r| &r.service == *sname) {
            continue;
        }
        // The author's policy, or the safe defaults when there is no block.
        let (nosniff, hsts) = match &service.security {
            Some(p) => (p.nosniff(), p.hsts_max_age_secs()),
            None => (true, None),
        };
        let hsts_lit = match hsts {
            Some(n) => n.to_string(),
            None => "null".to_string(),
        };
        let literal = format!("{{ nosniff: {nosniff}, hstsMaxAgeSecs: {hsts_lit} }}");
        out.push(SecurityService {
            service: (*sname).clone(),
            const_name: format!("__security_{sname}"),
            literal,
        });
    }
    out
}

#[derive(Debug, Clone)]
struct HttpRoute {
    service: String,
    method: HttpMethod,
    path: String,
    handler: Handler,
    /// v0.47: the handler's `by` clause names a Bearer actor, so its surface
    /// wrapper runs the verification seam and takes the request as its first
    /// argument.
    bearer: bool,
    /// v0.51: the handler's `by` clause names a Signature actor — the entry
    /// dispatch reads the raw body, verifies the HMAC, and parses the body from
    /// those same bytes.
    signature: Option<bynk_check::actors::SignatureSeam>,
    /// v0.52: the handler's `by` clause names a multi-actor sum — its wrapper
    /// owns the boundary, so the entry passes `request` (+ path params) and does
    /// not read or parse the body itself.
    sum: bool,
    /// v0.140 (ADR 0163): the GET handler's `@cache` freshness policy, if declared.
    /// `Some` only for a `GET` carrying a well-formed `@cache`; drives the
    /// `applyCache` `Cache-Control` stamp. The conditional `ETag`/`304` half is
    /// automatic for every eligible GET and needs no policy.
    cache: Option<CachePolicy>,
}

/// v0.140 (ADR 0163): a GET handler's opt-in freshness window, lowered from its
/// `@cache(maxAge:, scope:)` annotation to a `Cache-Control` directive.
#[derive(Debug, Clone)]
struct CachePolicy {
    /// `maxAge` in whole seconds (the `Cache-Control: max-age`).
    max_age_secs: i64,
    /// `public` or `private` — defaults to `private` so a *shared* cache never
    /// stores unless the author opts into `public`.
    scope: &'static str,
}

/// v0.140 (ADR 0163): extract a GET handler's `@cache` freshness policy from its
/// annotations, if present. Returns the `max-age` in whole seconds and the cache
/// scope (`private` by default). Only a `GET` yields a policy; project validation
/// (`bynk.http.cache_*`) has already rejected a `@cache` anywhere else, and a
/// malformed `maxAge` there, so a missing/ill-formed annotation here simply yields
/// `None` — the conditional `ETag` half still applies to every eligible GET.
fn cache_policy_for(h: &Handler) -> Option<CachePolicy> {
    if !matches!(
        h.kind,
        HandlerKind::Http {
            method: HttpMethod::Get,
            ..
        }
    ) {
        return None;
    }
    let ann = h.annotations.iter().find(|a| a.name.name == "cache")?;
    let mut max_age_millis: Option<i64> = None;
    let mut scope = "private";
    for arg in &ann.args {
        match arg.label.as_ref().map(|l| l.name.as_str()) {
            Some("maxAge") => {
                if let ExprKind::DurationLit { millis, .. } = &arg.value.kind {
                    max_age_millis = Some(*millis);
                }
            }
            Some("scope") => {
                if let ExprKind::Ident(id) = &arg.value.kind
                    && id.name == "public"
                {
                    scope = "public";
                }
            }
            _ => {}
        }
    }
    Some(CachePolicy {
        max_age_secs: max_age_millis? / 1000,
        scope,
    })
}

/// One `on cron` handler, identified by its service and per-service declaration
/// index (which together form its `cron_<service>_<index>` method key).
#[derive(Debug, Clone)]
struct CronRoute {
    service: String,
    index: usize,
    expr: String,
    /// Whether the handler declares the optional scheduled-time parameter.
    has_param: bool,
}

/// One `on queue` consumer, identified by its service and per-service
/// declaration index (its `queue_<service>_<index>` method key), plus the
/// message parameter's type for wire-format deserialisation.
#[derive(Debug, Clone)]
struct QueueRoute {
    service: String,
    index: usize,
    name: String,
    msg_type: Option<TypeRef>,
}

/// Generate the per-route dispatch block for one `on http` handler. The
/// router has already been entered via `try`; this block extracts path
/// parameters, deserialises the body (when present), invokes the handler,
/// and serialises the HttpResult through `httpResultToResponse`.
fn emit_http_route_dispatch(
    out: &mut String,
    route: &HttpRoute,
    cors_const: Option<&str>,
    security_const: Option<&str>,
) {
    let h = &route.handler;
    let method_key = http_handler_method_name(route.method, &route.path);
    let has_path_params = param_count(&route.path) > 0;
    let path_lit = route.path.replace('"', "\\\"");
    // v0.139 (ADR 0162 D3): a `GET` route also answers `HEAD` — the guard widens
    // so the `GET` handler runs, then the built response's body is stripped
    // below. Other methods match exactly.
    let method_guard = if route.method == HttpMethod::Get {
        "(method === \"GET\" || method === \"HEAD\")".to_string()
    } else {
        format!("method === \"{}\"", route.method.as_str())
    };
    let _ = writeln!(out, "      {{");
    if has_path_params {
        let _ = writeln!(out, "        const __m = matchPath(\"{path_lit}\", path);");
        let _ = writeln!(out, "        if ({method_guard} && __m) {{");
    } else {
        let _ = writeln!(
            out,
            "        if ({method_guard} && path === \"{path_lit}\") {{"
        );
    }
    // Extract path parameters from the matched params map.
    let mut call_args: Vec<String> = Vec::new();
    for p in &h.params {
        let pname = &p.name.name;
        if pname == "body" {
            // Body deserialisation happens below.
            continue;
        }
        // It's a path parameter — extract from __m.params and construct.
        let _ = writeln!(
            out,
            "          const __raw_{pname} = __m.params[\"{pname}\"];"
        );
        emit_path_param_construction(out, pname, &p.type_ref);
        call_args.push(pname.clone());
    }
    // Body parameter (POST/PUT/PATCH). A multi-actor sum route's wrapper reads
    // and parses the body itself (it must verify over the raw bytes first), so
    // the entry skips the body here and passes only `request`.
    if !route.sum
        && let Some(body_param) = h.params.iter().find(|p| p.name.name == "body")
    {
        let _ = writeln!(out, "          let __body_json: JsonValue;");
        if let Some(seam) = &route.signature {
            // v0.51: read the raw body once, verify the HMAC fail-closed (401),
            // then parse the body param from the *same* bytes (not a re-read /
            // re-serialisation — the signature is over these exact bytes).
            let secret = seam.secret.replace('\\', "\\\\").replace('"', "\\\"");
            let header = seam.header.replace('\\', "\\\\").replace('"', "\\\"");
            let _ = writeln!(out, "          let __raw: string;");
            let _ = writeln!(out, "          try {{");
            let _ = writeln!(out, "            __raw = await request.text();");
            let _ = writeln!(out, "          }} catch {{");
            let _ = writeln!(
                out,
                "            return new Response(JSON.stringify({{ kind: \"MalformedJson\", details: \"Invalid request body\" }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
            );
            let _ = writeln!(out, "          }}");
            let _ = writeln!(
                out,
                "          const __secret = (env as Record<string, unknown>)[\"{secret}\"] ?? (globalThis as {{ process?: {{ env?: Record<string, unknown> }} }}).process?.env?.[\"{secret}\"];"
            );
            let _ = writeln!(
                out,
                "          if (typeof __secret !== \"string\") return new Response(null, {{ status: 401 }});"
            );
            // Timestamp (when bound): must be present; passed to the verifier.
            let ts_expr = match &seam.timestamp_header {
                Some(th) => {
                    let th = th.replace('\\', "\\\\").replace('"', "\\\"");
                    let _ = writeln!(out, "          const __ts = request.headers.get(\"{th}\");");
                    let _ = writeln!(
                        out,
                        "          if (__ts === null) return new Response(null, {{ status: 401 }});"
                    );
                    "__ts"
                }
                None => "null",
            };
            let tol = match seam.tolerance_secs {
                Some(n) => n.to_string(),
                None => "null".to_string(),
            };
            let _ = writeln!(
                out,
                "          const __ok = await verifySignatureHmacSha256(__raw, __secret, request.headers.get(\"{header}\"), {ts_expr}, {tol});"
            );
            let _ = writeln!(
                out,
                "          if (!__ok) return new Response(null, {{ status: 401 }});"
            );
            let _ = writeln!(out, "          try {{");
            let _ = writeln!(
                out,
                "            __body_json = JSON.parse(__raw) as JsonValue;"
            );
            let _ = writeln!(out, "          }} catch {{");
            let _ = writeln!(
                out,
                "            return new Response(JSON.stringify({{ kind: \"MalformedJson\", details: \"Invalid request body\" }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
            );
            let _ = writeln!(out, "          }}");
        } else {
            let _ = writeln!(out, "          try {{");
            let _ = writeln!(
                out,
                "            __body_json = (await request.json()) as JsonValue;"
            );
            let _ = writeln!(out, "          }} catch {{");
            let _ = writeln!(
                out,
                "            return new Response(JSON.stringify({{ kind: \"MalformedJson\", details: \"Invalid request body\" }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
            );
            let _ = writeln!(out, "          }}");
        }
        let dser = deserialise_call(&body_param.type_ref, "__body_json", "$");
        let _ = writeln!(out, "          const __r_body = {dser};");
        let _ = writeln!(
            out,
            "          if (__r_body.tag === \"Err\") return new Response(JSON.stringify(__r_body.error), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
        );
        let _ = writeln!(out, "          const body = __r_body.value;");
        call_args.push("body".to_string());
    }
    // Invoke the handler and serialise the HttpResult. The handler is
    // wrapped on the surface so its deps are wired by `compose`. v0.47: a
    // Bearer wrapper takes the request first (it runs the verification seam).
    let surface_args = if route.bearer || route.sum {
        // The Bearer and sum wrappers take the request first (they run the
        // verification seam); a sum wrapper also reads/parses the body itself,
        // so `call_args` here carries only the path params.
        let mut a = vec!["request".to_string()];
        a.extend(call_args.iter().cloned());
        a.join(", ")
    } else {
        call_args.join(", ")
    };
    let _ = writeln!(
        out,
        "          const result = await surface.{method_key}({surface_args});",
    );
    let _ = route.service.as_str();
    let inner = http_result_inner(&h.return_type);
    let ser_fn = http_value_serialiser(&inner);
    // v0.140 (ADR 0163): a `GET` response carries a weak `ETag` (an `Ok` body gets
    // the validator via `weakEtag`), an optional `@cache` `Cache-Control`
    // (`applyCache`), and is answered `304` when the request revalidates
    // (`notModifiedIfMatch`) — composed innermost-first so CORS still stamps the
    // `304`. Non-`GET`/unsafe methods are byte-for-byte unchanged: no validator, no
    // conditional, no freshness (DECISION B).
    let response_expr = if route.method == HttpMethod::Get {
        let base = format!("httpResultToResponse(result, {ser_fn}, {{ weakEtag: true }})");
        let cached = match &route.cache {
            Some(p) => format!("applyCache({base}, {}, \"{}\")", p.max_age_secs, p.scope),
            None => base,
        };
        format!("notModifiedIfMatch({cached}, request)")
    } else {
        format!("httpResultToResponse(result, {ser_fn})")
    };
    // v0.131: a CORS-enabled service stamps the `Access-Control-*` headers onto
    // every real response, uniformly across variant families — including the
    // synthesised `304`, so a cross-origin revalidation stays readable.
    let cors_expr = match cors_const {
        Some(c) => format!("applyCors({response_expr}, {c}, request.headers.get(\"origin\"))"),
        None => response_expr,
    };
    // v0.141 (ADR 0164): stamp the security headers (`nosniff` by default, HSTS
    // opt-in) onto every response — outside `applyCors`, but the two set disjoint
    // headers so order is immaterial. Every `from http` service has a policy, so
    // this is `Some` on every route; it also flows onto the `304` (inside
    // `response_expr`) and the `HEAD` answer (`headResponse` copies the headers).
    let build_expr = match security_const {
        Some(s) => format!("applySecurityHeaders({cors_expr}, {s})"),
        None => cors_expr,
    };
    // v0.139 (ADR 0162 D3): on a `GET` route, a `HEAD` returns the same status
    // and headers with an empty body — the handler ran, so the headers are the
    // real ones a `GET` would produce; `headResponse` discards the body without
    // reading it (a `Streaming` body is never drained).
    if route.method == HttpMethod::Get {
        let _ = writeln!(out, "          const __response = {build_expr};");
        let _ = writeln!(
            out,
            "          return method === \"HEAD\" ? headResponse(__response) : __response;"
        );
    } else {
        let _ = writeln!(out, "          return {build_expr};");
    }
    let _ = writeln!(out, "        }}");
    let _ = writeln!(out, "      }}");
}

/// Synthesise a deps object literal for invoking a handler from the
/// fetch entry point. Mirrors `compose`'s deps construction so handlers
/// see the same shape whether invoked through the surface or directly.
fn emit_call_handler_dispatch(
    out: &mut String,
    sname: &str,
    h: &Handler,
    actors: &std::collections::HashMap<String, bynk_syntax::ast::ActorDecl>,
) {
    // v0.54: a `by c: Caller` handler reads the caller's context name from the
    // `X-Bynk-Caller` header (stamped by `callService`) and threads it into the
    // surface call. The internal channel is trusted, but a missing caller means
    // a malformed / non-Bynk call — fail-closed (the `Internal` 401-analogue).
    let caller_args = if bynk_check::actors::caller_binder_for(h, actors).is_some() {
        let _ = writeln!(
            out,
            "            const __caller = request.headers.get(\"X-Bynk-Caller\");"
        );
        let _ = writeln!(
            out,
            "            if (__caller === null || __caller === \"\") return new Response(JSON.stringify({{ kind: \"Unauthorized\", details: \"missing caller identity\" }}), {{ status: 401, headers: {{ \"content-type\": \"application/json\" }} }});"
        );
        "__caller, "
    } else {
        ""
    };
    if h.params.len() == 1 {
        let p = &h.params[0];
        let pname = &p.name.name;
        let dser_call = deserialise_call(&p.type_ref, "args", "$");
        let _ = writeln!(out, "            const __r_{pname} = {dser_call};");
        let _ = writeln!(
            out,
            "            if (__r_{pname}.tag === \"Err\") return new Response(JSON.stringify(__r_{pname}.error), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
        );
        let _ = writeln!(out, "            const {pname} = __r_{pname}.value;");
        let _ = writeln!(
            out,
            "            const result = await surface.{sname}({caller_args}{pname});"
        );
    } else {
        let _ = writeln!(
            out,
            "            if (typeof args !== \"object\" || args === null || Array.isArray(args)) return new Response(JSON.stringify({{ kind: \"StructuralMismatch\", path: \"$\", expected: \"object\", actual: typeof args }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
        );
        let _ = writeln!(
            out,
            "            const argsObj = args as {{ [k: string]: JsonValue }};"
        );
        let mut names = Vec::new();
        for p in &h.params {
            let pname = &p.name.name;
            let dser = deserialise_call(
                &p.type_ref,
                &format!("argsObj[\"{pname}\"]"),
                &format!("$.{pname}"),
            );
            let _ = writeln!(out, "            const __r_{pname} = {dser};");
            let _ = writeln!(
                out,
                "            if (__r_{pname}.tag === \"Err\") return new Response(JSON.stringify(__r_{pname}.error), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
            );
            let _ = writeln!(out, "            const {pname} = __r_{pname}.value;");
            names.push(pname.clone());
        }
        // Prepend the caller (when present) without a dangling comma for the
        // zero-param case.
        let mut call_args: Vec<&str> = Vec::new();
        if !caller_args.is_empty() {
            call_args.push("__caller");
        }
        call_args.extend(names.iter().map(String::as_str));
        let _ = writeln!(
            out,
            "            const result = await surface.{sname}({});",
            call_args.join(", ")
        );
    }
    let ser_expr = serialise_call(&h.return_type, "result");
    let _ = writeln!(out, "            const body = {ser_expr};");
    let _ = writeln!(
        out,
        "            return new Response(JSON.stringify(body), {{ status: 200, headers: {{ \"content-type\": \"application/json\" }} }});"
    );
}

/// Emit code that converts the raw path-parameter string to the parameter's
/// declared type. For plain `String`, this is a direct assignment; for
/// refined or opaque `String`-based types we lower through `T.of(...)`
/// which performs refinement validation and returns 400 on failure.
fn emit_path_param_construction(out: &mut String, pname: &str, t: &TypeRef) {
    match t {
        TypeRef::Base(BaseType::String, _) => {
            let _ = writeln!(out, "          const {pname} = __raw_{pname};");
        }
        TypeRef::Named(id) => {
            let _ = writeln!(
                out,
                "          const __r_{pname} = handlers.{}.of(__raw_{pname});",
                id.name
            );
            let _ = writeln!(
                out,
                "          if (__r_{pname}.tag === \"Err\") return new Response(JSON.stringify({{ kind: \"RefinementViolation\", path: \"path.{pname}\", violation: __r_{pname}.error }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
            );
            let _ = writeln!(out, "          const {pname} = __r_{pname}.value;");
        }
        _ => {
            // Other shapes are rejected by the static check; emit a fallback.
            let _ = writeln!(out, "          const {pname} = __raw_{pname} as any;");
        }
    }
}

/// Strip the `Effect[HttpResult[_]]` wrapper to expose the inner payload type
/// `T`. Used to choose the right serialiser when emitting the HttpResult
/// body.
fn http_result_inner(t: &TypeRef) -> TypeRef {
    let inner = match t {
        TypeRef::Effect(t, _) => t.as_ref(),
        other => other,
    };
    match inner {
        TypeRef::HttpResult(payload, _) => (**payload).clone(),
        other => other.clone(),
    }
}

/// TypeScript expression that serialises a value of the `HttpResult[T]`
/// payload type to a `JsonValue`. Wraps the per-type serialise helper for
/// named types; uses pass-through for base types.
fn http_value_serialiser(t: &TypeRef) -> String {
    match t {
        TypeRef::Base(_, _) => "(v: any) => v as JsonValue".to_string(),
        // v0.20a: function types are confined to non-boundary positions
        // (`bynk.types.function_at_boundary`), so the serialisation machinery
        // can never legally see one.
        TypeRef::Fn(..)
        | TypeRef::Query(..)
        | TypeRef::Stream(..)
        | TypeRef::Connection(..)
        | TypeRef::History(..) => {
            unreachable!("function/query/stream types are rejected at boundaries")
        }
        TypeRef::Unit(_) => "(_v: any) => null".to_string(),
        TypeRef::Named(id) => format!("handlers.serialise_{}", id.name),
        TypeRef::Result(_, _, _)
        | TypeRef::Option(_, _)
        | TypeRef::List(_, _)
        | TypeRef::Map(_, _, _) => {
            let inst_name = inner_ts_name(t);
            format!("handlers.serialise_{inst_name}")
        }
        TypeRef::Effect(inner, _) => http_value_serialiser(inner),
        TypeRef::HttpResult(_, _)
        | TypeRef::QueueResult(_)
        | TypeRef::ValidationError(_)
        | TypeRef::JsonError(_) => "(v: any) => v as JsonValue".to_string(),
    }
}

pub(crate) fn deserialise_call(t: &TypeRef, json_expr: &str, path: &str) -> String {
    match t {
        // v0.20a: function types are confined to non-boundary positions
        // (`bynk.types.function_at_boundary`), so the serialisation machinery
        // can never legally see one.
        TypeRef::Fn(..)
        | TypeRef::Query(..)
        | TypeRef::Stream(..)
        | TypeRef::Connection(..)
        | TypeRef::History(..) => {
            unreachable!("function/query/stream types are rejected at boundaries")
        }
        // v0.110 (ADR 0142 D8): a `Bytes` at a `workers` boundary is diagnosed
        // as not-yet-supported by the project validator, so this arm is
        // normally unreachable. Emit a correct base64 decode anyway (defence in
        // depth — never a silent mis-encode) rather than fall through to the
        // number/string typeof check.
        TypeRef::Base(BaseType::Bytes, _) => {
            format!(
                "(typeof {json_expr} === \"string\" ? ((__b) => __b.tag === \"Some\" ? Ok(__b.value) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"base64 string\", actual: \"invalid base64\" }})) (__bynkBytesFromBase64({json_expr})) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"base64 string\", actual: typeof {json_expr} }})) as Result<any, BoundaryError>"
            )
        }
        TypeRef::Base(b, _) => {
            let typeof_str = match b {
                BaseType::Int => "number",
                BaseType::String => "string",
                BaseType::Bool => "boolean",
                BaseType::Float => "number",
                BaseType::Duration | BaseType::Instant => "number",
                // Unreachable: handled by the dedicated `Bytes` arm above.
                BaseType::Bytes => "string",
            };
            // v0.22b: bare `Int` params validate integrality (ADR 0049). v0.86:
            // a `Duration` is whole milliseconds, so it validates integrality too.
            if *b == BaseType::Int || *b == BaseType::Duration || *b == BaseType::Instant {
                return format!(
                    "(typeof {json_expr} === \"number\" && Number.isInteger({json_expr}) ? Ok({json_expr}) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"integer\", actual: String({json_expr}) }}) as Result<any, BoundaryError>)"
                );
            }
            // v0.21: boundary `Float` values are finite (ADR 0040).
            if *b == BaseType::Float {
                return format!(
                    "(typeof {json_expr} === \"number\" && Number.isFinite({json_expr}) ? Ok({json_expr}) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"finite number\", actual: String({json_expr}) }}) as Result<any, BoundaryError>)"
                );
            }
            format!(
                "(typeof {json_expr} === \"{typeof_str}\" ? Ok({json_expr}) : Err({{ kind: \"StructuralMismatch\", path: \"{path}\", expected: \"{typeof_str}\", actual: typeof {json_expr} }}) as Result<any, BoundaryError>)"
            )
        }
        TypeRef::Named(id) => {
            format!("handlers.deserialise_{}({json_expr}, \"{path}\")", id.name)
        }
        TypeRef::Result(_, _, _)
        | TypeRef::Option(_, _)
        | TypeRef::List(_, _)
        | TypeRef::Map(_, _, _) => {
            let inst_name = inner_ts_name(t);
            format!("handlers.deserialise_{inst_name}({json_expr}, \"{path}\")")
        }
        TypeRef::Effect(inner, _) => deserialise_call(inner, json_expr, path),
        TypeRef::HttpResult(_, _)
        | TypeRef::QueueResult(_)
        | TypeRef::ValidationError(_)
        | TypeRef::JsonError(_)
        | TypeRef::Unit(_) => {
            format!("Ok({json_expr} as any) as Result<any, BoundaryError>")
        }
    }
}

fn serialise_call(t: &TypeRef, value: &str) -> String {
    match t {
        // v0.21: serialising a non-finite `Float` is a contract violation
        // (ADR 0040) — `JSON.stringify(NaN)` would silently produce `null`.
        TypeRef::Base(BaseType::Float, _) => format!(
            "((v: number) => {{ if (!Number.isFinite(v)) throw new Error(\"non-finite Float at boundary\"); return v as JsonValue; }})({value})"
        ),
        TypeRef::Base(_, _) => format!("{value} as JsonValue"),
        // v0.20a: function types are confined to non-boundary positions
        // (`bynk.types.function_at_boundary`), so the serialisation machinery
        // can never legally see one.
        TypeRef::Fn(..)
        | TypeRef::Query(..)
        | TypeRef::Stream(..)
        | TypeRef::Connection(..)
        | TypeRef::History(..) => {
            unreachable!("function/query/stream types are rejected at boundaries")
        }
        TypeRef::Named(id) => format!("handlers.serialise_{}({value})", id.name),
        TypeRef::Result(_, _, _)
        | TypeRef::Option(_, _)
        | TypeRef::List(_, _)
        | TypeRef::Map(_, _, _) => {
            let inst_name = inner_ts_name(t);
            format!("handlers.serialise_{inst_name}({value})")
        }
        TypeRef::Effect(inner, _) => serialise_call(inner, value),
        // Unit serialises to JSON `null` (the value is `void`, which cannot be
        // cast to `JsonValue` under `tsc --strict`).
        TypeRef::Unit(_) => "null".to_string(),
        TypeRef::HttpResult(_, _)
        | TypeRef::QueueResult(_)
        | TypeRef::ValidationError(_)
        | TypeRef::JsonError(_) => {
            format!("{value} as JsonValue")
        }
    }
}

fn inner_ts_name(t: &TypeRef) -> String {
    match t {
        TypeRef::Base(b, _) => b.name().to_string(),
        // v0.20a: function types are confined to non-boundary positions
        // (`bynk.types.function_at_boundary`), so the serialisation machinery
        // can never legally see one.
        TypeRef::Fn(..)
        | TypeRef::Query(..)
        | TypeRef::Stream(..)
        | TypeRef::Connection(..)
        | TypeRef::History(..) => {
            unreachable!("function/query/stream types are rejected at boundaries")
        }
        TypeRef::Named(id) => id.name.clone(),
        TypeRef::Result(a, b, _) => format!("Result_{}_{}", inner_ts_name(a), inner_ts_name(b)),
        TypeRef::Option(a, _) => format!("Option_{}", inner_ts_name(a)),
        TypeRef::Effect(a, _) => format!("Effect_{}", inner_ts_name(a)),
        TypeRef::HttpResult(a, _) => format!("HttpResult_{}", inner_ts_name(a)),
        TypeRef::List(a, _) => format!("List_{}", inner_ts_name(a)),
        TypeRef::Map(k, v, _) => format!("Map_{}_{}", inner_ts_name(k), inner_ts_name(v)),
        TypeRef::QueueResult(_) => "QueueResult".to_string(),
        TypeRef::ValidationError(_) => "ValidationError".to_string(),
        TypeRef::JsonError(_) => "JsonError".to_string(),
        TypeRef::Unit(_) => "Unit".to_string(),
    }
}
