// Doctor — one-shot public-readiness audit of a LIVE Gluecron instance.
//
// Certifies the surface an external AI platform needs: endpoint health,
// the OAuth provider (discovery, DCR, token-endpoint error semantics),
// and the MCP server (anonymous handshake, auth challenges per RFC 6750,
// authenticated tool calls, tool-manifest drift vs local code).
//
// Run:  bun run scripts/doctor.ts [--full] [--md]
// Env:  GLUECRON_HOST  target instance (default https://gluecron.com)
//       GLUECRON_PAT   optional PAT — enables the authenticated sections
//                      (without it they report SKIP, not FAIL)
// Exit: 0 iff every non-skipped check passes.
//
// Companion: scripts/agent-journey.ts exercises the full write path
// (push → PR → review → merge). Keep doctor read-only + residue-free,
// except one recognizably-named `doctor-*` OAuth client from the DCR
// round-trip (RFC 7592 delete isn't implemented).

import {
  runChecks,
  formatTable,
  CHECKS,
  type CheckResult,
} from "../src/lib/post-deploy-smoke";
import { defaultTools } from "../src/lib/mcp-tools";

const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
const PAT = process.env.GLUECRON_PAT?.trim() || "";
const FULL = process.argv.includes("--full");
const MD = process.argv.includes("--md");

interface Row extends CheckResult {
  skipped?: boolean;
}

function row(name: string, ok: boolean, opts: Partial<Row> = {}): Row {
  return { name, url: opts.url ?? "", status: opts.status ?? 0, durationMs: opts.durationMs ?? 0, ok, ...(opts.error ? { error: opts.error } : {}), ...(opts.skipped ? { skipped: true } : {}) };
}

function skip(name: string, why: string): Row {
  return { name, url: "", status: 0, durationMs: 0, ok: true, skipped: true, error: why };
}

async function timed<T>(fn: () => Promise<T>): Promise<[T, number]> {
  const t0 = Date.now();
  const v = await fn();
  return [v, Date.now() - t0];
}

async function jget(path: string, headers: Record<string, string> = {}) {
  const res = await fetch(HOST + path, { headers });
  const text = await res.text();
  let json: any = null;
  try { json = JSON.parse(text); } catch { /* non-JSON */ }
  return { status: res.status, headers: res.headers, text, json };
}

async function jpost(path: string, body: unknown, headers: Record<string, string> = {}) {
  const res = await fetch(HOST + path, {
    method: "POST",
    headers: { "content-type": "application/json", ...headers },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  let json: any = null;
  try { json = JSON.parse(text); } catch { /* non-JSON */ }
  return { status: res.status, headers: res.headers, text, json };
}

let rpcId = 0;
async function mcpRpc(method: string, params?: unknown, bearer?: string) {
  const headers: Record<string, string> = {};
  if (bearer) headers.authorization = `Bearer ${bearer}`;
  return jpost("/mcp", { jsonrpc: "2.0", id: ++rpcId, method, ...(params !== undefined ? { params } : {}) }, headers);
}

async function mcpCall(tool: string, args: Record<string, unknown>, bearer?: string) {
  return mcpRpc("tools/call", { name: tool, arguments: args }, bearer);
}

// ─── Section (a): endpoint smoke — the deploy-gate 15 ───────────────

async function sectionSmoke(): Promise<Row[]> {
  const summary = await runChecks({ baseUrl: HOST, fetchImpl: fetch as any, checks: CHECKS, log: () => undefined });
  return summary.results;
}

// ─── Section (b): OAuth provider surface ────────────────────────────

async function sectionOauth(): Promise<Row[]> {
  const out: Row[] = [];

  const [as, asMs] = await timed(() => jget("/.well-known/oauth-authorization-server"));
  const grants: string[] = as.json?.grant_types_supported ?? [];
  out.push(row("as-metadata advertises refresh_token", as.status === 200 && grants.includes("refresh_token"), {
    url: "/.well-known/oauth-authorization-server", status: as.status, durationMs: asMs,
    error: as.status !== 200 ? "not 200" : grants.includes("refresh_token") ? undefined : `grants: ${grants.join(",")}`,
  }));
  out.push(row("as-metadata has registration_endpoint", Boolean(as.json?.registration_endpoint), { status: as.status }));

  for (const p of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
    const [pr, prMs] = await timed(() => jget(p));
    out.push(row(`protected-resource ${p.endsWith("mcp") ? "(mcp)" : "(root)"}`, pr.status === 200 && typeof pr.json?.resource === "string", { url: p, status: pr.status, durationMs: prMs }));
  }

  // DCR round-trip. Residue: one client row named doctor-<ts>.
  const [reg, regMs] = await timed(() =>
    jpost("/oauth/register", {
      client_name: `doctor-${new Date().toISOString().replace(/[:.]/g, "-")}`,
      redirect_uris: ["https://localhost/doctor-callback"],
      token_endpoint_auth_method: "none",
    })
  );
  const clientId: string | undefined = reg.json?.client_id;
  out.push(row("DCR round-trip returns client_id", (reg.status === 200 || reg.status === 201) && Boolean(clientId), {
    url: "/oauth/register", status: reg.status, durationMs: regMs, error: clientId ? undefined : reg.text.slice(0, 120),
  }));

  // Token endpoint error semantics (the signals refresh clients key off).
  if (clientId) {
    const badCode = await jpost("/oauth/token", { grant_type: "authorization_code", code: "garbage", client_id: clientId, redirect_uri: "https://localhost/doctor-callback", code_verifier: "x".repeat(43) });
    out.push(row("token: garbage code → invalid_grant", badCode.status === 400 && badCode.json?.error === "invalid_grant", { status: badCode.status, error: badCode.json?.error }));

    const badRefresh = await jpost("/oauth/token", { grant_type: "refresh_token", refresh_token: "glcr_garbage", client_id: clientId });
    out.push(row("token: garbage refresh → invalid_grant", badRefresh.status === 400 && badRefresh.json?.error === "invalid_grant", { status: badRefresh.status, error: badRefresh.json?.error }));

    const badGrant = await jpost("/oauth/token", { grant_type: "password", client_id: clientId });
    out.push(row("token: bogus grant_type → unsupported_grant_type", badGrant.status === 400 && badGrant.json?.error === "unsupported_grant_type", { status: badGrant.status, error: badGrant.json?.error }));
  } else {
    out.push(skip("token endpoint error semantics", "no client_id from DCR"));
  }

  return out;
}

// ─── Section (c): MCP anonymous surface + RFC 6750 challenges ───────

async function sectionMcpAnon(): Promise<Row[]> {
  const out: Row[] = [];

  const [init, initMs] = await timed(() => mcpRpc("initialize"));
  out.push(row("anonymous initialize → 200", init.status === 200 && Boolean(init.json?.result?.protocolVersion), { status: init.status, durationMs: initMs }));

  const [list, listMs] = await timed(() => mcpRpc("tools/list"));
  const liveNames: string[] = (list.json?.result?.tools ?? []).map((t: any) => t.name).sort();
  const localNames = Object.keys(defaultTools()).sort();
  const missing = localNames.filter((n) => !liveNames.includes(n));
  const extra = liveNames.filter((n) => !localNames.includes(n));
  out.push(row(`tools/list matches local manifest (${localNames.length} tools)`, list.status === 200 && missing.length === 0 && extra.length === 0, {
    status: list.status, durationMs: listMs,
    error: missing.length || extra.length ? `live missing: [${missing.join(",")}] live extra: [${extra.join(",")}]` : undefined,
  }));

  const anon = await mcpCall("gluecron_repo_search", { query: "x" });
  const anonWww = anon.headers.get("www-authenticate") || "";
  out.push(row("anonymous tools/call → 401 challenge (no error=)", anon.status === 401 && anonWww.includes("resource_metadata=") && !anonWww.includes("error="), {
    status: anon.status, error: anonWww ? undefined : "missing WWW-Authenticate",
  }));

  // Live proof of the 2026-07-14 fix: dead bearer must signal invalid_token
  // on the HANDSHAKE, not just tools/call — this is what breaks the
  // reconnect loop.
  for (const method of ["initialize", "tools/list"]) {
    const dead = await mcpRpc(method, undefined, "glct_deadbeef");
    const www = dead.headers.get("www-authenticate") || "";
    out.push(row(`dead bearer on ${method} → 401 invalid_token`, dead.status === 401 && www.includes('error="invalid_token"'), {
      status: dead.status, error: dead.status === 401 ? (www.includes('error="invalid_token"') ? undefined : "no invalid_token attr") : "not 401 (fix not deployed?)",
    }));
  }

  return out;
}

// ─── Section (d): MCP authenticated reads (needs GLUECRON_PAT) ──────

async function sectionMcpAuthed(): Promise<Row[]> {
  if (!PAT) return [skip("authenticated MCP reads", "GLUECRON_PAT not set")];
  const out: Row[] = [];

  const probes: Array<[string, Record<string, unknown>]> = [
    ["gluecron_search_repos", { query: "gluecron" }],
    ["gluecron_repo_health", { owner: "ccantynz", repo: "Gluecron.com" }],
    ["gluecron_list_prs", { owner: "ccantynz", repo: "Gluecron.com", state: "open" }],
  ];
  for (const [tool, args] of probes) {
    const [res, ms] = await timed(() => mcpCall(tool, args, PAT));
    const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
    out.push(row(`authed ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
  }
  return out;
}

// ─── Section (e): --full read-only tool matrix ──────────────────────

// Canned args per read-only tool; write tools belong to agent-journey.ts.
const READ_MATRIX: Record<string, Record<string, unknown>> = {
  gluecron_repo_search: { query: "gluecron" },
  gluecron_search_repos: { query: "gluecron" },
  gluecron_repo_health: { owner: "ccantynz", repo: "Gluecron.com" },
  gluecron_list_prs: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
  gluecron_search_prs: { owner: "ccantynz", repo: "Gluecron.com", query: "fix" },
  gluecron_repo_list_issues: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
  gluecron_search_issues: { owner: "ccantynz", repo: "Gluecron.com", query: "deploy" },
  gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", ref: "main", path: "" },
  gluecron_repo_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
  gluecron_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
  gluecron_clone_url: { owner: "ccantynz", repo: "Gluecron.com" },
  gluecron_pr_status_summary: { owner: "ccantynz", repo: "Gluecron.com" },
};

async function sectionMatrix(): Promise<Row[]> {
  if (!FULL) return [skip("read-only tool matrix", "pass --full to run")];
  if (!PAT) return [skip("read-only tool matrix", "GLUECRON_PAT not set")];
  const out: Row[] = [];
  for (const [tool, args] of Object.entries(READ_MATRIX)) {
    const [res, ms] = await timed(() => mcpCall(tool, args, PAT));
    const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
    out.push(row(`matrix ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
  }
  return out;
}

// ─── Main ───────────────────────────────────────────────────────────

async function main() {
  console.log(`# Gluecron doctor — ${HOST} — ${new Date().toISOString()}`);
  console.log(`# PAT: ${PAT ? "present" : "absent (authed sections will SKIP)"}  mode: ${FULL ? "full" : "standard"}\n`);

  const sections: Array<[string, () => Promise<Row[]>]> = [
    ["a. Endpoint smoke (deploy-gate 15)", sectionSmoke],
    ["b. OAuth provider surface", sectionOauth],
    ["c. MCP anonymous surface + RFC 6750", sectionMcpAnon],
    ["d. MCP authenticated reads", sectionMcpAuthed],
    ["e. Read-only tool matrix", sectionMatrix],
  ];

  let failed = 0;
  let skipped = 0;
  const mdLines: string[] = [];

  for (const [title, run] of sections) {
    let rows: Row[];
    try {
      rows = await run();
    } catch (err) {
      rows = [row(title, false, { error: `section crashed: ${(err as Error).message}` })];
    }
    const sectionFailed = rows.filter((r) => !r.ok && !r.skipped).length;
    const sectionSkipped = rows.filter((r) => r.skipped).length;
    failed += sectionFailed;
    skipped += sectionSkipped;

    console.log(`\n## ${title} — ${sectionFailed === 0 ? "PASS" : `FAIL (${sectionFailed})`}${sectionSkipped ? ` (${sectionSkipped} skipped)` : ""}\n`);
    console.log(formatTable(rows.map((r) => (r.skipped ? { ...r, ok: true, error: `SKIP: ${r.error}` } : r))));

    if (MD) {
      mdLines.push(`### ${title}`, "");
      for (const r of rows) mdLines.push(`- ${r.skipped ? "⬜" : r.ok ? "✅" : "🚫"} ${r.name}${r.error ? ` — ${r.error}` : ""}`);
      mdLines.push("");
    }
  }

  if (MD) console.log(`\n<!-- markdown -->\n${mdLines.join("\n")}`);

  console.log(`\n# doctor: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${skipped ? ` — ${skipped} skipped` : ""}`);
  process.exit(failed === 0 ? 0 : 1);
}

main().catch((err) => {
  console.error("doctor crashed:", err);
  process.exit(2);
});
