#!/usr/bin/env bun
/**
 * mcp-config renderer.
 *
 * Usage:
 *   bun tools/mcp-config/render.ts --host <name> [--out <path>] [--check]
 *                                   [--format claude-json|codex-toml]
 *                                   [--allow-missing <channel>]...
 *                                   [--env-file <path>]
 *
 * Phase 1 goal: produce byte-identical output to the existing hand-rolled
 * .mcp.json on each host. NOT wired into any supervisor — see README.
 */

import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { resolve, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { canonicalize, type Json } from "./canonical.ts";
import { getChannel, channels as channelRegistry } from "./channels/index.ts";
import type { ChannelDef, HostChannelRef, HostDef, LaunchStyle, McpServerEntry } from "./types.ts";

const HERE = dirname(fileURLToPath(import.meta.url));

interface CliFlags {
  host: string;
  out?: string;
  check: boolean;
  allowMissing: Set<string>;
  envFile?: string;
  format: "claude-json" | "codex-toml";
  merge: boolean;
  onlyHttp: boolean;
}

function parseArgs(argv: string[]): CliFlags {
  const flags: CliFlags = {
    host: "",
    check: false,
    allowMissing: new Set(),
    format: "claude-json",
    merge: false,
    onlyHttp: false,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    switch (a) {
      case "--host":
        flags.host = argv[++i] ?? "";
        break;
      case "--out":
        flags.out = argv[++i];
        break;
      case "--check":
        flags.check = true;
        break;
      case "--format": {
        const format = argv[++i] ?? "";
        if (format !== "claude-json" && format !== "codex-toml") {
          throw new Error(`Unknown format: "${format}"`);
        }
        flags.format = format;
        break;
      }
      case "--merge":
        flags.merge = true;
        break;
      case "--only-http":
        flags.onlyHttp = true;
        break;
      case "--allow-missing":
        flags.allowMissing.add(argv[++i] ?? "");
        break;
      case "--env-file":
        flags.envFile = argv[++i];
        break;
      case "-h":
      case "--help":
        printHelp();
        process.exit(0);
        break;
      default:
        if (a.startsWith("--")) {
          throw new Error(`Unknown flag: ${a}`);
        }
    }
  }
  if (!flags.host) {
    printHelp();
    throw new Error("--host is required");
  }
  return flags;
}

function printHelp(): void {
  process.stderr.write(`mcp-config renderer (phase 1)

  --host <name>            Host descriptor id (e.g. claude-cloud, mbp-work)
  --out <path>             Write to file (default: stdout)
  --check                  Print to stdout only; do not write a file
  --format <format>        claude-json (default) or codex-toml
  --merge                  With --format codex-toml and --out, replace only
                           generated mcp_servers.* sections in the target file
  --only-http              With --format codex-toml, render only URL MCPs
  --allow-missing <chan>   Skip a channel whose required env is missing
  --env-file <path>        Explicit path to per-host secrets .env

If --env-file is not given, the renderer looks for
  tools/mcp-config/hosts/<host>.env
relative to the repo root.
`);
}

/** Minimal dotenv parser (KEY=VALUE, # comments, optional quotes). No expansion. */
function parseDotenv(text: string): Record<string, string> {
  const out: Record<string, string> = {};
  for (const raw of text.split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line.startsWith("#")) continue;
    const eq = line.indexOf("=");
    if (eq <= 0) continue;
    const key = line.slice(0, eq).trim();
    let val = line.slice(eq + 1).trim();
    if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
      val = val.slice(1, -1);
    }
    out[key] = val;
  }
  return out;
}

async function loadHost(hostId: string): Promise<HostDef> {
  const path = resolve(HERE, "hosts", `${hostId}.ts`);
  if (!existsSync(path)) {
    throw new Error(`Unknown host: "${hostId}" (no descriptor at ${path})`);
  }
  const mod = (await import(path)) as { host?: HostDef; default?: HostDef };
  const host = mod.host ?? mod.default;
  if (!host) {
    throw new Error(`Host descriptor ${path} must export { host } or default`);
  }
  return host;
}

async function loadEnvFile(hostId: string, explicit?: string): Promise<Record<string, string>> {
  const candidate = explicit ?? resolve(HERE, "hosts", `${hostId}.env`);
  if (!existsSync(candidate)) return {};
  const text = await readFile(candidate, "utf8");
  return parseDotenv(text);
}

export interface RenderInput {
  host: HostDef;
  /** Env from per-host .env file (secrets + config). */
  hostEnv: Record<string, string>;
  /** Channels to skip on missing required vars. */
  allowMissing?: Set<string>;
  /** Codex-only helper: omit stdio/process-launched servers. */
  onlyHttp?: boolean;
}

function resolveToolDir(host: HostDef, ref: HostChannelRef, def: ChannelDef): string {
  if (ref.toolDirAbs) return ref.toolDirAbs;
  return `${host.repoRoot}/${def.toolDirRel}`;
}

/** Build the env block for a modeled channel, applying defaults + host overrides. */
function buildChannelEnv(
  def: ChannelDef,
  ref: HostChannelRef,
  hostEnv: Record<string, string>,
): { env: Record<string, string>; missingRequired: string[] } {
  const env: Record<string, string> = {};
  const missing: string[] = [];
  for (const spec of def.env) {
    const val = ref.env?.[spec.key] ?? hostEnv[spec.key] ?? spec.default;
    if (val !== undefined && val !== "") {
      env[spec.key] = val;
    } else if (spec.required) {
      missing.push(spec.key);
    }
  }
  // Allow host overrides to inject env vars not declared on the channel.
  if (ref.env) {
    for (const [k, v] of Object.entries(ref.env)) {
      if (!(k in env)) env[k] = v;
    }
  }
  return { env, missingRequired: missing };
}

type LaunchSpec =
  | { kind: "process"; command: string; args: string[]; cwd?: string }
  | {
      kind: "http";
      type: "streamable-http";
      url: string;
      headers: Record<string, string>;
    };

function buildLaunch(
  host: HostDef,
  def: ChannelDef,
  ref: HostChannelRef,
  toolDir: string,
  resolvedEnv: Record<string, string>,
): LaunchSpec {
  const style: LaunchStyle = ref.launchStyle ?? def.defaultLaunchStyle;
  const bun = ref.bunOverride ?? host.binaries.bun;
  const entryAbs = `${toolDir}/${def.entryFile}`;
  switch (style) {
    case "bash-cd-exec":
      return {
        kind: "process",
        command: "bash",
        args: ["-c", `cd ${toolDir} && exec ${bun} run ${def.entryFile}`],
      };
    case "bun-run-abs":
      return {
        kind: "process",
        command: bun,
        args: ["run", entryAbs],
        cwd: toolDir,
      };
    case "bun-run-args":
      // bb-imessage style: `bun run --cwd <dir> --shell=bun --silent start`
      return {
        kind: "process",
        command: bun,
        args: ["run", "--cwd", toolDir, "--shell=bun", "--silent", "start"],
      };
    case "http": {
      if (!def.httpUrl) {
        throw new Error(
          `Channel "${def.name}" uses launch style "http" but has no httpUrl builder.`,
        );
      }
      const bearerKey = def.httpBearerEnvKey ?? "MCP_BEARER_TOKEN";
      const bearer = resolvedEnv[bearerKey];
      // Defense-in-depth: buildChannelEnv normally validates required envs
      // before we get here, but a channel could declare the bearer key as
      // optional (or omit it from env[]) and still ship as HTTP. Failing
      // fast here keeps the renderer honest in both cases.
      if (!bearer) {
        throw new Error(
          `Channel "${def.name}" launch style "http" requires env "${bearerKey}" to be set.`,
        );
      }
      return {
        kind: "http",
        type: "streamable-http",
        url: def.httpUrl(host),
        headers: {
          Authorization: `Bearer ${bearer}`,
        },
      };
    }
    default: {
      // Exhaustiveness check.
      const _exhaustive: never = style;
      throw new Error(`Unhandled launch style: ${_exhaustive as string}`);
    }
  }
}

export interface RenderResult {
  json: string;
  serverCount: number;
  skippedChannels: string[];
  warnings: string[];
}

interface BuiltServers {
  servers: Record<string, McpServerEntry>;
  skippedChannels: string[];
  warnings: string[];
}

function asMcpServerEntry(raw: Json): McpServerEntry {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
    throw new Error("extraServers entries must be JSON objects");
  }
  return raw as unknown as McpServerEntry;
}

function buildServers(input: RenderInput): BuiltServers {
  const { host, hostEnv, allowMissing = new Set<string>() } = input;
  const servers: Record<string, McpServerEntry> = {};
  const skipped: string[] = [];
  const warnings: string[] = [];

  for (const ref of host.channels) {
    if (!(ref.channel in channelRegistry)) {
      throw new Error(
        `Host ${host.hostname} references unknown channel "${ref.channel}". ` +
          `Add it to tools/mcp-config/channels/ or remove from descriptor.`,
      );
    }
    const def = getChannel(ref.channel);
    const style: LaunchStyle = ref.launchStyle ?? def.defaultLaunchStyle;
    if (input.onlyHttp && style !== "http") {
      continue;
    }
    const { env, missingRequired } = buildChannelEnv(def, ref, hostEnv);
    if (missingRequired.length > 0) {
      if (allowMissing.has(ref.channel)) {
        skipped.push(ref.channel);
        warnings.push(
          `[warn] skipping channel "${ref.channel}" — missing required env: ${missingRequired.join(", ")}`,
        );
        continue;
      }
      throw new Error(
        `Channel "${ref.channel}" missing required env vars on ${host.hostname}: ` +
          `${missingRequired.join(", ")}. ` +
          `Set them in tools/mcp-config/hosts/${host.hostname}.env, or pass --allow-missing ${ref.channel}.`,
      );
    }
    const toolDir = resolveToolDir(host, ref, def);
    const launch = buildLaunch(host, def, ref, toolDir, env);
    const entry: McpServerEntry =
      launch.kind === "http"
        ? {
            type: launch.type,
            url: launch.url,
            headers: launch.headers,
          }
        : {
            command: launch.command,
            args: launch.args,
            ...(launch.cwd ? { cwd: launch.cwd } : {}),
            ...(Object.keys(env).length > 0 ? { env } : {}),
          };
    servers[def.name] = entry;
  }

  // Passthrough: raw mcp server defs that aren't yet modeled.
  if (host.extraServers) {
    for (const [name, raw] of Object.entries(host.extraServers)) {
      if (name in servers) {
        throw new Error(
          `extraServers["${name}"] on ${host.hostname} collides with a modeled channel`,
        );
      }
      servers[name] = asMcpServerEntry(raw);
    }
  }

  return {
    servers,
    skippedChannels: skipped,
    warnings,
  };
}

export function render(input: RenderInput): RenderResult {
  const built = buildServers(input);
  const json = canonicalize({ mcpServers: built.servers as unknown as Json });
  return {
    json,
    serverCount: Object.keys(built.servers).length,
    skippedChannels: built.skippedChannels,
    warnings: built.warnings,
  };
}

export interface CodexTomlResult {
  toml: string;
  serverCount: number;
  serverNames: string[];
  skippedChannels: string[];
  warnings: string[];
}

function tomlString(value: string): string {
  return JSON.stringify(value);
}

function tomlBareKey(key: string): string {
  return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
}

function tomlInlineKey(key: string): string {
  return tomlString(key);
}

function tomlStringArray(values: string[]): string {
  return `[${values.map(tomlString).join(", ")}]`;
}

function tomlInlineStringMap(values: Record<string, string>): string {
  const entries = Object.entries(values).sort(([a], [b]) => a.localeCompare(b));
  return `{ ${entries.map(([k, v]) => `${tomlInlineKey(k)} = ${tomlString(v)}`).join(", ")} }`;
}

function renderCodexServer(name: string, entry: McpServerEntry): string {
  const lines = [`[mcp_servers.${tomlBareKey(name)}]`];
  if (entry.url) {
    const headers = entry.http_headers ?? entry.headers;
    lines.push(`url = ${tomlString(entry.url)}`);
    if (headers && Object.keys(headers).length > 0) {
      lines.push(`http_headers = ${tomlInlineStringMap(headers)}`);
    }
    return lines.join("\n");
  }

  if (!entry.command) {
    throw new Error(`MCP server "${name}" must have either url or command`);
  }
  lines.push(`command = ${tomlString(entry.command)}`);
  if (entry.args) {
    lines.push(`args = ${tomlStringArray(entry.args)}`);
  }
  if (entry.cwd) {
    lines.push(`cwd = ${tomlString(entry.cwd)}`);
  }
  if (entry.env && Object.keys(entry.env).length > 0) {
    lines.push("", `[mcp_servers.${tomlBareKey(name)}.env]`);
    for (const [key, value] of Object.entries(entry.env).sort(([a], [b]) => a.localeCompare(b))) {
      lines.push(`${tomlBareKey(key)} = ${tomlString(value)}`);
    }
  }
  return lines.join("\n");
}

export function renderCodexToml(input: RenderInput): CodexTomlResult {
  const built = buildServers(input);
  const names = Object.keys(built.servers)
    .filter((name) => !input.onlyHttp || Boolean(built.servers[name].url))
    .sort((a, b) => a.localeCompare(b));
  const toml =
    names.map((name) => renderCodexServer(name, built.servers[name])).join("\n\n") +
    (names.length > 0 ? "\n" : "");
  return {
    toml,
    serverCount: names.length,
    serverNames: names,
    skippedChannels: built.skippedChannels,
    warnings: built.warnings,
  };
}

function readTomlPathFirstKey(path: string): string | null {
  const trimmed = path.trim();
  if (trimmed.startsWith('"')) {
    const match = trimmed.match(/^"((?:\\.|[^"\\])*)"/);
    if (!match) return null;
    // Only decodes keys emitted by tomlBareKey, which uses JSON-compatible
    // TOML basic strings for quoted section path segments.
    return JSON.parse(`"${match[1]}"`) as string;
  }
  const match = trimmed.match(/^([A-Za-z0-9_-]+)/);
  return match?.[1] ?? null;
}

function isManagedCodexMcpSection(line: string, managedNames: Set<string>): boolean {
  const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
  if (!match) return false;
  const section = match[1].trim();
  if (!section.startsWith("mcp_servers.")) return false;
  const firstKey = readTomlPathFirstKey(section.slice("mcp_servers.".length));
  return firstKey !== null && managedNames.has(firstKey);
}

export function mergeCodexConfig(
  existingToml: string,
  generatedToml: string,
  managedServerNames: string[],
): string {
  const managedNames = new Set(managedServerNames);
  const kept: string[] = [];
  let skipping = false;
  for (const line of existingToml.split(/\r?\n/)) {
    if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(line)) {
      skipping = isManagedCodexMcpSection(line, managedNames);
    }
    if (!skipping) {
      kept.push(line);
    }
  }

  const keptText = kept.join("\n").trimEnd();
  const generatedText = generatedToml.trimEnd();
  if (!generatedText) {
    return keptText ? `${keptText}\n` : "";
  }
  return keptText ? `${keptText}\n\n${generatedText}\n` : `${generatedText}\n`;
}

async function main(): Promise<void> {
  const flags = parseArgs(process.argv.slice(2));
  if (flags.merge && flags.format !== "codex-toml") {
    throw new Error("--merge is only supported with --format codex-toml");
  }
  if (flags.merge && !flags.out) {
    throw new Error("--merge requires --out (no file to merge into)");
  }
  const host = await loadHost(flags.host);
  const hostEnv = await loadEnvFile(flags.host, flags.envFile);
  const result =
    flags.format === "codex-toml"
      ? renderCodexToml({
          host,
          hostEnv,
          allowMissing: flags.allowMissing,
          onlyHttp: flags.onlyHttp,
        })
      : render({
          host,
          hostEnv,
          allowMissing: flags.allowMissing,
        });
  for (const w of result.warnings) process.stderr.write(w + "\n");
  const output = "toml" in result ? result.toml : result.json;

  if (flags.check || !flags.out) {
    process.stdout.write(output);
    return;
  }
  const finalOutput =
    flags.merge && "toml" in result
      ? mergeCodexConfig(
          existsSync(flags.out) ? await readFile(flags.out, "utf8") : "",
          result.toml,
          result.serverNames,
        )
      : output;
  await writeFile(flags.out, finalOutput, "utf8");
  // Stamp the rendered location for debugging supervisor wiring later.
  const stamp = {
    host: host.hostname,
    format: flags.format,
    renderedAt: new Date().toISOString(),
    out: flags.out,
    serverCount: result.serverCount,
    skippedChannels: result.skippedChannels,
    ...(flags.format === "codex-toml" && "serverNames" in result
      ? { serverNames: result.serverNames }
      : {}),
  };
  await writeFile(join(HERE, ".rendered-by"), JSON.stringify(stamp, null, 2) + "\n", "utf8");
  process.stderr.write(
    `wrote ${flags.out} (${result.serverCount} servers, ${result.skippedChannels.length} skipped)\n`,
  );
}

if (import.meta.main) {
  main().catch((err) => {
    process.stderr.write(`error: ${(err as Error).message}\n`);
    process.exit(1);
  });
}
