/**
 * Daemon-owned Slack OAuth loopback for the hosted in-app "Connect Slack" button.
 *
 * A mobile client can't run a loopback server, and Slack requires an https
 * redirect URI, so the DAEMON runs a short-lived self-signed https server on one
 * of the Slack app's registered loopback ports. `StartConnectIntegration(slack)`
 * starts it (bound to the requesting tenant's `userId`) and hands the client the
 * Slack consent URL. On the callback the daemon exchanges the code, stores the
 * user token under that tenant (canonical `slack-oauth:{userId}`), warms the MCP
 * proxy, and serves a page that returns the in-app browser to
 * `nomos://oauth-complete?provider=slack` so the web-auth sheet closes and the
 * Connectors list refreshes.
 *
 * Mirrors the CLI `nomos slack connect-mcp` loopback, but hosted-aware (per user,
 * not the fixed `local` owner) and non-interactive (redirect back to the app
 * instead of a terminal + HTML page).
 */

import type { Server } from "node:https";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createLogger } from "../lib/logger.ts";
import {
  buildSlackAuthorizeUrl,
  exchangeSlackCode,
  slackOAuthClientCreds,
  storeSlackOAuth,
} from "../auth/slack-oauth.ts";

const log = createLogger("slack-connect-loopback");

/**
 * The Slack app's registered loopback redirect ports (see
 * `slack-app-manifest.json`). The redirect_uri we send MUST be one Slack has
 * allow-listed, so we only ever bind these.
 */
const LOOPBACK_PORTS = [9877, 9876, 8934];
/** Abandon a started-but-never-completed connect after this long. */
const CONNECT_TTL_MS = 5 * 60_000;
/** Custom-scheme URL the app's `ASWebAuthenticationSession` intercepts to close. */
const APP_RETURN_URL = "nomos://oauth-complete?provider=slack";

interface Pending {
  server: Server;
  timer: ReturnType<typeof setTimeout>;
  state: string;
  userId: string;
  redirectUri: string;
}

// One in-flight connect at a time (a single owner per daemon in practice). A new
// connect supersedes an older pending one, freeing the port.
let pending: Pending | null = null;

function closePending(): void {
  if (!pending) return;
  clearTimeout(pending.timer);
  try {
    pending.server.close();
  } catch {
    // already closing/closed
  }
  pending = null;
}

/**
 * A STABLE self-signed localhost cert for the loopback, cached at
 * `~/.nomos/slack-loopback.{key,crt}` and reused across connects. Being stable, it
 * can be trusted ONCE (e.g. `xcrun simctl keychain booted add-root-cert
 * ~/.nomos/slack-loopback.crt`, or added to the login keychain) so the in-app
 * browser stops showing the "not private" warning — which is what was blocking the
 * OAuth callback from being delivered (the browser accepts the warning but won't
 * re-send the request, so the callback never lands). Includes `subjectAltName=
 * DNS:localhost` (required by iOS since 13) and `CA:TRUE` so it validates as a
 * trusted root for localhost. Slack requires an https redirect URI, so https it is.
 */
async function selfSignedCert(): Promise<{ key: string; cert: string }> {
  const fs = await import("node:fs");
  const os = await import("node:os");
  const path = await import("node:path");
  const { execFileSync } = await import("node:child_process");

  const dir = path.join(os.homedir(), ".nomos");
  const keyPath = path.join(dir, "slack-loopback.key");
  const certPath = path.join(dir, "slack-loopback.crt");

  try {
    const key = fs.readFileSync(keyPath, "utf-8");
    const cert = fs.readFileSync(certPath, "utf-8");
    if (key.includes("PRIVATE KEY") && cert.includes("CERTIFICATE")) return { key, cert };
  } catch {
    // not cached yet — generate below
  }

  fs.mkdirSync(dir, { recursive: true });
  const cfgPath = path.join(dir, "slack-loopback-openssl.cnf");
  fs.writeFileSync(
    cfgPath,
    [
      "[req]",
      "distinguished_name=dn",
      "x509_extensions=v3",
      "prompt=no",
      "[dn]",
      "CN=localhost",
      "[v3]",
      "subjectAltName=DNS:localhost",
      "basicConstraints=critical,CA:TRUE",
    ].join("\n"),
  );
  execFileSync(
    "openssl",
    [
      "req",
      "-x509",
      "-newkey",
      "rsa:2048",
      "-keyout",
      keyPath,
      "-out",
      certPath,
      "-days",
      "3650",
      "-nodes",
      "-config",
      cfgPath,
    ],
    { stdio: ["ignore", "ignore", "ignore"] },
  );

  const key = fs.readFileSync(keyPath, "utf-8");
  const cert = fs.readFileSync(certPath, "utf-8");
  if (!key.includes("PRIVATE KEY") || !cert.includes("CERTIFICATE")) {
    throw new Error("failed to generate the loopback certificate (is openssl installed?)");
  }
  return { key, cert };
}

/** Bind an https server on the first registered loopback port that's free. */
async function listenOnRegisteredPort(
  https: typeof import("node:https"),
  opts: { key: string; cert: string },
): Promise<{ server: Server; port: number }> {
  for (const port of LOOPBACK_PORTS) {
    const server = https.createServer({ key: opts.key, cert: opts.cert });
    const ok = await new Promise<boolean>((resolve) => {
      const onError = () => {
        server.close();
        resolve(false);
      };
      server.once("error", onError);
      server.listen(port, "127.0.0.1", () => {
        server.off("error", onError);
        resolve(true);
      });
    });
    if (ok) return { server, port };
  }
  throw new Error(
    "no free Slack loopback port (9877/9876/8934) — is another connect already in progress?",
  );
}

function htmlAttr(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/"/g, "&quot;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

/**
 * Return the in-app browser to the app. We serve an HTML page (not a bare 302):
 * over the self-signed loopback, `ASWebAuthenticationSession` does NOT reliably
 * intercept a server 302 to the custom scheme after the user taps through the
 * cert warning. The page does a JS `location.replace` AND shows a tappable
 * `nomos://` link — a user-tapped navigation to the callback scheme is always
 * intercepted — so the sheet closes either way.
 */
function returnPage(res: ServerResponse, errCode?: string): void {
  const appUrl = errCode
    ? `${APP_RETURN_URL}&error=${encodeURIComponent(errCode)}`
    : APP_RETURN_URL;
  const ok = !errCode;
  const title = ok ? "Slack connected" : "Slack connect failed";
  const sub = ok ? "Returning to Nomos…" : "You can close this and try again.";
  const jsUrl = JSON.stringify(appUrl); // safe JS string literal
  const attrUrl = htmlAttr(appUrl); // safe in href + meta content
  // Fire the nomos:// return every way we can — ASWebAuthenticationSession is flaky
  // about intercepting a single redirect on the first (cert-bypassed) load, so we use
  // a meta refresh + repeated JS navigations + a user-tappable link (always intercepted).
  const html =
    `<!doctype html><html><head><meta charset="utf-8">` +
    `<meta name="viewport" content="width=device-width, initial-scale=1">` +
    `<meta http-equiv="refresh" content="0; url=${attrUrl}"><title>${title}</title>` +
    `<script>var u=${jsUrl};function go(){try{location.replace(u)}catch(e){}` +
    `try{location.href=u}catch(e){}}go();setTimeout(go,100);setTimeout(go,500);` +
    `document.addEventListener("DOMContentLoaded",go);</script>` +
    `<style>body{font-family:-apple-system,system-ui,sans-serif;text-align:center;` +
    `padding:56px 24px;color:#1c1814;background:#f2ebdd}h2{margin:0 0 8px}` +
    `a{display:inline-block;margin-top:20px;color:#c46a4a;font-size:17px;font-weight:600;` +
    `text-decoration:none}</style></head><body>` +
    `<h2>${title}</h2><p>${sub}</p><a href="${attrUrl}">Return to Nomos</a>` +
    `</body></html>`;
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
  res.end(html);
}

/** Terminal callback outcome: bounce to the app, then tear down the loopback. */
function finish(res: ServerResponse, errCode?: string): void {
  returnPage(res, errCode);
  closePending();
}

async function handleCallback(req: IncomingMessage, res: ServerResponse): Promise<void> {
  // Parse against a fixed base — we only need the path + query, and `pending`
  // (hence its redirectUri) may already be gone for a late/expired callback.
  const url = new URL(req.url ?? "/", "https://localhost");
  if (url.pathname !== "/oauth/callback") {
    // A stray request (favicon/preflight), NOT the OAuth return: 404 and keep the
    // loopback open for the real callback (the TTL timer bounds the wait). Slack
    // only ever redirects to the exact registered /oauth/callback path.
    res.writeHead(404);
    res.end("not found");
    return;
  }
  // From here this IS the OAuth return, so it MUST always return the browser to
  // the app (a page that navigates to nomos://) — never a bare status body — or
  // the ASWebAuthenticationSession sheet hangs on a blank page.
  const ctx = pending;
  if (!ctx) {
    // Timed out or superseded before Slack redirected back — close the sheet cleanly.
    returnPage(res, "expired");
    return;
  }
  const slackError = url.searchParams.get("error");
  if (slackError) return finish(res, slackError);
  const returnedState = url.searchParams.get("state");
  // The state is a 256-bit random nonce, so an exact match is the CSRF guard
  // (a mismatch means the callback isn't for this connect).
  if (!returnedState || returnedState !== ctx.state) return finish(res, "invalid_state");
  const code = url.searchParams.get("code");
  if (!code) return finish(res, "no_code");

  try {
    const r = await exchangeSlackCode({ code, redirectUri: ctx.redirectUri });
    await storeSlackOAuth(ctx.userId, r);
    // Warm the persistent proxy so the very next turn already has Slack tools.
    try {
      const { warmSlackMcp } = await import("../sdk/slack-mcp-proxy.ts");
      await warmSlackMcp(r.accessToken);
    } catch {
      // non-fatal — the proxy connects lazily on first use
    }
    log.info({ userId: ctx.userId, teamId: r.teamId }, "Slack connected (loopback)");
    finish(res);
  } catch (err) {
    log.warn(
      { err: err instanceof Error ? err.message : err },
      "Slack connect token exchange failed",
    );
    finish(res, "exchange_failed");
  }
}

/**
 * Start the loopback for `userId` and return the Slack consent URL for the client
 * to open. Throws if Slack OAuth client creds aren't configured.
 */
export async function startSlackConnectLoopback(userId: string): Promise<string> {
  // Throws a clear error when SLACK_OAUTH_CLIENT_ID/SECRET (or the slack-oauth
  // integration) aren't set, instead of starting a server that can't complete.
  const { clientId } = await slackOAuthClientCreds();

  // Supersede any previous pending connect (frees the port + cancels its timer).
  closePending();

  const https = await import("node:https");
  const crypto = await import("node:crypto");
  const { key, cert } = await selfSignedCert();
  const state = crypto.randomBytes(32).toString("hex");
  const { server, port } = await listenOnRegisteredPort(https, { key, cert });
  const redirectUri = `https://localhost:${port}/oauth/callback`;

  const timer = setTimeout(() => {
    log.warn({ userId }, "Slack connect loopback timed out; closing");
    closePending();
  }, CONNECT_TTL_MS);
  // Don't keep the daemon event loop alive on this timer.
  timer.unref?.();

  pending = { server, timer, state, userId, redirectUri };
  server.on("request", (req, res) => {
    void handleCallback(req, res);
  });

  const url = buildSlackAuthorizeUrl({ clientId, redirectUri, state });
  log.info({ userId, port }, "Slack connect loopback started");
  return url;
}
