/**
 * Popout server — a tiny loopback HTTP server that renders a single message in
 * its own OS window. The daemon points a native msger WebView window at it
 * (svc.popoutWindow → showMessageBoxEx({url}) in bin/mailx.ts); a plain browser
 * can load the same URL, which is the future "browser option". msger's custom
 * protocol doesn't propagate to child windows, so a separate window can't use
 * the normal IPC client; this serves a self-contained view instead.
 *
 * Transport choice (Bob 2026-06-29): loopback TCP on an OS-assigned EPHEMERAL
 * port (`listen(0)`) — no hardcoded port to collide, identical on every
 * platform. A browser window can't load HTTP over a named pipe / Unix socket,
 * so pipes aren't an option here (they'd suit a CLI/IPC client). Loopback TCP is
 * reachable by any local process, so every route is gated on a per-launch random
 * token. Future: the `msgapi` channel could serve this without HTTP at all.
 *
 * Toolbar actions (Bob 2026-07-06 "when I want to do the reply I need it to be
 * editable"): the popout page can't host compose (no IPC bridge, no Quill), so
 * Reply / Reply All / Forward / Edit-as-new / Flag / Delete POST back to
 * /action, which hands them to `onAction` — bin/mailx.ts forwards that as a
 * `popoutAction` event to the MAIN window, where app.ts opens the real,
 * editable compose overlay (same path as the in-window popout's toolbar).
 */
import http from "node:http";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { dispatch } from "@bobfrankston/mailx-service/jsonrpc.js";

export interface PopoutServerInfo {
    port: number;
    token: string;
    /** Agent tokens (written to ~/.rmfmail/agent.json for third-party apps
     *  and AI agents). `triage` = reads + flagging; `full` = everything the
     *  UI can do. Sending remains a human act by convention — the MCP layer
     *  exposes drafts, not send. */
    agentTokens: { triage: string; full: string };
    /** Push a daemon event to every connected popout/secondary window
     *  (SSE). The main window gets events over the msger service channel;
     *  popout-served pages subscribe to GET /events and receive the same
     *  payloads, so secondary windows live-update too. */
    broadcastEvent: (evt: any) => void;
}

export interface PopoutAction {
    action: string;
    accountId: string;
    uid: number;
    folderId?: number;
}

/** svc must expose getMessage(accountId, uid, allowRemote, folderId) and
 *  openExternal(url). `onAction` receives toolbar actions from popout windows
 *  (reply/replyAll/forward/editAsNew/flag/delete) for routing to the main
 *  window. Returns null if the server can't bind (popout disabled). */
export async function startPopoutServer(svc: any, onAction?: (act: PopoutAction) => void): Promise<PopoutServerInfo | null> {
    const token = crypto.randomBytes(18).toString("hex");
    const agentTokens = { triage: crypto.randomBytes(18).toString("hex"), full: crypto.randomBytes(18).toString("hex") };
    // Actions a TRIAGE-scoped agent may call: reads plus flag/read-state.
    // Deliberately excludes send/move/delete/settings and every popout/window
    // action. Full-scope agents get the whole dispatcher.
    const TRIAGE_ACTIONS = new Set([
        "getVersion", "getAccounts", "getFolders", "getMessages", "getUnifiedInbox",
        "getMessage", "searchMessages", "getThreadMessages", "getAttachment",
        "getMessageSource", "searchContacts", "listContacts", "getCalendarEvents",
        "getCalendars", "getTasks", "getOutboxStatus", "getSyncPending",
        "getDiagnostics", "getPriorityLists", "hasCcHistoryTo", "hasBccHistoryTo",
        "updateFlags", "markFolderRead", "logClientEvent",
    ]);
    // Static root for /app/<token>/... — the app package root, so pages under
    // client/ can reach ../styles, ../../node_modules etc. exactly as they do
    // through msger's custom protocol.
    const appRoot = path.resolve(import.meta.dirname, "..");
    // Connected SSE subscribers (secondary windows / popouts).
    const sseClients = new Set<http.ServerResponse>();

    const server = http.createServer(async (req, res) => {
        try {
            const url = new URL(req.url || "/", "http://127.0.0.1");

            // Static app files: /app/<token>/<relpath>. Token travels in the
            // path so the page's RELATIVE asset/module loads (css, bundles,
            // node_modules imports) stay inside the gate without rewriting.
            const staticMatch = url.pathname.match(/^\/app\/([0-9a-f]+)\/(.*)$/);
            if (req.method === "GET" && staticMatch) {
                if (staticMatch[1] !== token) {
                    res.writeHead(403).end("forbidden");
                    return;
                }
                await serveStatic(appRoot, staticMatch[2], token, res, svc);
                return;
            }

            // Token gate on every request — loopback TCP is locally reachable.
            // Three tokens: the per-launch window token (full surface), and two
            // agent tokens published in ~/.rmfmail/agent.json.
            const presented = url.searchParams.get("t");
            const isWindowToken = presented === token;
            const isFullAgent = presented === agentTokens.full;
            const isTriageAgent = presented === agentTokens.triage;
            if (!isWindowToken && !isFullAgent && !isTriageAgent) {
                res.writeHead(403).end("forbidden");
                return;
            }

            // Server-sent events: daemon → popout/secondary windows. The
            // bridge subscribes with EventSource and feeds each payload to
            // _msgapiServiceEvent — same shape the msger service channel
            // delivers to the main window.
            if (req.method === "GET" && url.pathname === "/events") {
                res.writeHead(200, {
                    "content-type": "text/event-stream",
                    "cache-control": "no-store",
                    "connection": "keep-alive",
                });
                res.write(":connected\n\n");
                sseClients.add(res);
                req.on("close", () => sseClients.delete(res));
                return;
            }

            // Full mailxapi surface over HTTP: the popout compose page's
            // bridge (client/lib/popout-bridge.ts) posts the SAME
            // { _action, _cbid, ...params } payload the WebView IPC channel
            // carries, and gets the dispatcher's { _cbid, result | error }
            // back. One endpoint = the whole service, no per-method routes.
            if (req.method === "POST" && url.pathname === "/rpc") {
                let body = "";
                for await (const chunk of req) body += chunk;
                let rpcReq: any = null;
                try { rpcReq = JSON.parse(body); } catch { /* handled below */ }
                if (!rpcReq || typeof rpcReq._action !== "string") {
                    res.writeHead(400).end("bad rpc request");
                    return;
                }
                if (isTriageAgent && !TRIAGE_ACTIONS.has(rpcReq._action)) {
                    res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
                    res.end(JSON.stringify({ _cbid: rpcReq._cbid, error: `action "${rpcReq._action}" requires the full agent token (triage scope is reads + flags)` }));
                    return;
                }
                const rpcRes = await dispatch(svc, rpcReq);
                res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
                res.end(JSON.stringify(rpcRes));
                return;
            }
            const a = url.searchParams.get("a") || "";
            const u = Number(url.searchParams.get("u"));
            const fRaw = url.searchParams.get("f");
            const f = fRaw != null && fRaw !== "" ? Number(fRaw) : undefined;

            if (req.method === "GET" && url.pathname === "/v") {
                const msg = await svc.getMessage(a, u, false, f);
                res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
                res.end(renderPage(msg, a, u, f, token));
                return;
            }
            if (req.method === "GET" && url.pathname === "/body") {
                const msg = await svc.getMessage(a, u, false, f);
                res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
                res.end(renderBody(msg));
                return;
            }
            if (req.method === "POST" && url.pathname === "/open") {
                let body = "";
                for await (const chunk of req) body += chunk;
                let link = "";
                try { link = (JSON.parse(body || "{}").url) || ""; } catch { /* */ }
                if (link && /^https?:\/\//i.test(link)) {
                    await svc.openExternal(link).catch(() => { /* best-effort */ });
                }
                res.writeHead(204).end();
                return;
            }
            if (req.method === "POST" && url.pathname === "/action") {
                let body = "";
                for await (const chunk of req) body += chunk;
                let action = "";
                try { action = (JSON.parse(body || "{}").action) || ""; } catch { /* */ }
                const known = ["reply", "replyAll", "forward", "editAsNew", "flag", "delete"];
                if (action && known.includes(action) && onAction) {
                    onAction({ action, accountId: a, uid: u, folderId: f });
                    res.writeHead(204).end();
                } else {
                    res.writeHead(400).end("unknown action");
                }
                return;
            }
            res.writeHead(404).end("not found");
        } catch (e: any) {
            try { res.writeHead(500).end(String(e?.message || e)); } catch { /* */ }
        }
    });

    const broadcastEvent = (evt: any): void => {
        if (sseClients.size === 0) return;
        let payload: string;
        try { payload = `data: ${JSON.stringify(evt)}

`; } catch { return; }
        for (const c of sseClients) {
            try { c.write(payload); } catch { sseClients.delete(c); }
        }
    };

    return new Promise((resolve) => {
        server.once("error", (e: any) => {
            console.error(`  [popout] server failed to start: ${e?.message || e}`);
            resolve(null);
        });
        server.listen(0, "127.0.0.1", () => {
            const addr = server.address();
            const port = typeof addr === "object" && addr ? addr.port : 0;
            if (port) {
                console.log(`  [popout] window server on http://127.0.0.1:${port} (loopback, token-gated)`);
                resolve({ port, token, agentTokens, broadcastEvent });
            } else {
                resolve(null);
            }
        });
    });
}

const STATIC_MIME: Record<string, string> = {
    html: "text/html; charset=utf-8",
    js: "text/javascript; charset=utf-8",
    mjs: "text/javascript; charset=utf-8",
    css: "text/css; charset=utf-8",
    json: "application/json",
    map: "application/json",
    svg: "image/svg+xml",
    png: "image/png",
    ico: "image/x-icon",
    woff2: "font/woff2",
    woff: "font/woff",
    wasm: "application/wasm",
};

/** Serve one file from the app root for the popout compose window. HTML gets
 *  the IPC-over-HTTP bridge injected at the top of <head>: config + bridge
 *  (window.ipc → POST /rpc) + the REAL mailxapi.js — so the page runs the
 *  byte-identical API surface the main WebView gets via initScript. */
async function serveStatic(appRoot: string, relRaw: string, token: string, res: http.ServerResponse, svc?: any): Promise<void> {
    const rel = decodeURIComponent(relRaw);
    const abs = path.resolve(appRoot, rel);
    // Traversal guard — resolved path must stay inside the app root.
    if (abs !== appRoot && !abs.startsWith(appRoot + path.sep)) {
        res.writeHead(403).end("forbidden");
        return;
    }
    const ext = (abs.split(".").pop() || "").toLowerCase();
    const mime = STATIC_MIME[ext] || "application/octet-stream";
    let data: Buffer;
    try {
        data = await fs.readFile(abs);
    } catch {
        res.writeHead(404).end("not found");
        return;
    }
    // no-store everywhere: the token (and port) change per daemon run, but a
    // stale cached bundle behind a same-shaped URL is exactly the class of
    // bug that burned us before (see memory: webview_bundle_cache_stale).
    const headers = { "content-type": mime, "cache-control": "no-store" };
    if (ext === "html") {
        // popout-bridge.js is type="module": tsc (NodeNext) emits `export {}`
        // which is a SyntaxError in a classic script. Module scripts run
        // after parse but in DOCUMENT ORDER, so the bridge still executes
        // before the page's own module (compose.bundle.js) makes any RPC.
        // mailxapi.js stays classic — it's the same file every host injects.
        //
        // Settings snapshot baked into the page: popout windows live on a
        // per-port origin whose localStorage caches (editor type, font size)
        // start EMPTY on every daemon restart — a reply opened in Quill when
        // TinyMCE was selected (Bob 2026-07-12). Pages prefer this snapshot
        // over their localStorage caches; ui subset keeps the payload small.
        let bootUi: any = null;
        try { bootUi = (await svc?.getSettings?.())?.ui ?? null; } catch { /* page falls back to defaults */ }
        const inject = `<script>window.__rmfPopoutRpc={url:"/rpc?t=${token}",events:"/events?t=${token}"};window.__rmfBootSettings=${JSON.stringify({ ui: bootUi })};</script>`
            + `<script type="module" src="/app/${token}/client/lib/popout-bridge.js"></script>`
            + `<script src="/app/${token}/client/lib/mailxapi.js"></script>`;
        const html = data.toString("utf-8").replace(/<head([^>]*)>/i, (m) => m + inject);
        res.writeHead(200, headers).end(html);
    } else {
        res.writeHead(200, headers).end(data);
    }
}

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

function addrLine(list: any): string {
    if (!Array.isArray(list)) return "";
    return list.map((x: any) => esc(x?.name ? `${x.name} <${x.address}>` : (x?.address || ""))).filter(Boolean).join(", ");
}

/** The outer popout page: header + action toolbar + sandboxed body iframe.
 *  Toolbar buttons POST to /action (→ daemon → main window opens editable
 *  compose / flags / deletes). A small script relays the iframe's link clicks
 *  back to /open so they land in the OS browser. */
function renderPage(msg: any, a: string, u: number, f: number | undefined, token: string): string {
    const subject = esc(msg?.subject || "(no subject)");
    const from = esc(msg?.from?.name ? `${msg.from.name} <${msg.from.address}>` : (msg?.from?.address || ""));
    const to = addrLine(msg?.to);
    const date = msg?.date ? esc(new Date(msg.date).toLocaleString()) : "";
    const q = `a=${encodeURIComponent(a)}&u=${u}&f=${f ?? ""}&t=${encodeURIComponent(token)}`;
    // Same glyph set as the in-window popout toolbar (message-viewer.ts) so
    // the two surfaces read as the same feature.
    const btn = (action: string, glyph: string, title: string): string =>
        `<button class="act" data-action="${action}" title="${esc(title)}">${glyph}</button>`;
    return `<!doctype html><html><head><meta charset="utf-8"><title>${subject}</title>
<style>
  html,body{margin:0;height:100%;font-family:system-ui,Segoe UI,sans-serif;color:#111;background:#fff;}
  body{display:flex;flex-direction:column;}
  .hdr{padding:10px 14px 6px;border-bottom:1px solid #ddd;flex:0 0 auto;}
  .subj{font-size:1.15em;font-weight:600;margin-bottom:4px;}
  .meta{font-size:0.85em;color:#555;line-height:1.4;}
  .bar{display:flex;gap:6px;padding:6px 14px 8px;align-items:center;}
  .act{font-size:1em;line-height:1;padding:5px 10px;border:1px solid #ccc;border-radius:4px;background:#f6f6f6;cursor:pointer;}
  .act:hover{background:#e9e9e9;}
  .note{font-size:0.78em;color:#888;margin-left:auto;}
  iframe{flex:1 1 auto;border:none;width:100%;}
</style></head><body>
  <div class="hdr">
    <div class="subj">${subject}</div>
    <div class="meta">${from ? "From: " + from + "<br>" : ""}${to ? "To: " + to + "<br>" : ""}${date}</div>
    <div class="bar">
      ${btn("reply", "↩", "Reply")}
      ${btn("replyAll", "↩↩", "Reply All")}
      ${btn("forward", "→", "Forward")}
      ${btn("editAsNew", "⧉", "Edit as new message")}
      ${btn("flag", "★", "Flag")}
      ${btn("delete", "🗑", "Delete")}
      <span class="note" id="note"></span>
    </div>
  </div>
  <iframe src="/body?${q}" sandbox="allow-scripts allow-same-origin"></iframe>
  <script>
    var T=${JSON.stringify(token)};
    var Q=${JSON.stringify(q)};
    window.addEventListener("message", function(e){
      if(e.data && e.data.type==="popout-link" && e.data.url){
        fetch("/open?t="+encodeURIComponent(T),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({url:e.data.url})});
      }
    });
    document.querySelectorAll(".act").forEach(function(b){
      b.addEventListener("click", function(){
        var action=b.getAttribute("data-action");
        fetch("/action?"+Q,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({action:action})})
          .then(function(r){
            if(!r.ok){document.getElementById("note").textContent="action failed";return;}
            if(action==="reply"||action==="replyAll"||action==="forward"||action==="editAsNew"){
              document.getElementById("note").textContent="compose opened in main window";
            }else if(action==="delete"){
              // Daemon closes the msger window; browser-opened windows can
              // close themselves (script-opened → close() allowed).
              try{window.close();}catch(_){}
            }
          })
          .catch(function(){document.getElementById("note").textContent="action failed";});
      });
    });
  </script>
</body></html>`;
}

/** The body document loaded into the sandboxed iframe. Body HTML is already
 *  server-sanitized (sanitizeHtml); the inline script intercepts link clicks and
 *  hands them to the parent page (→ /open → OS browser) instead of navigating. */
function renderBody(msg: any): string {
    const body = (msg?.bodyHtml) || (msg?.bodyText ? `<pre style="white-space:pre-wrap">${esc(msg.bodyText)}</pre>` : "<em>(no content)</em>");
    return `<!doctype html><html><head><meta charset="utf-8">
<style>body{margin:12px;font-family:system-ui,Segoe UI,sans-serif;color:#111;}img{max-width:100%;height:auto;}</style>
</head><body>${body}
<script>
document.addEventListener("click",function(e){
  var t=e.target; var a=t&&t.closest?t.closest("a[href]"):null;
  if(!a||!a.href)return;
  e.preventDefault();
  try{parent.postMessage({type:"popout-link",url:a.href},"*");}catch(_){}
},true);
</script></body></html>`;
}
