/// <reference types="bun-types" />
/**
 * Out-of-band send approval for curogram_send_text.
 *
 * THREAT MODEL
 * ------------
 * The MCP tool caller is an LLM. Any gate the LLM can satisfy by itself is not a
 * gate. The previous `confirmed:true` boolean was self-grantable: the same model
 * that drafts the message can set the flag. This module replaces that with a
 * genuine out-of-band human gate that the tool-calling LLM CANNOT satisfy.
 *
 * THE HANDSHAKE
 * -------------
 *   1. LLM calls curogram_send_text WITHOUT a valid approval. The server does
 *      NOT send. It mints a pending-send record and writes it to
 *          <state>/pending-sends/<id>.json   (dir 0700, file 0600)
 *      The record contains a server-generated, single-use `token` and a content
 *      hash binding the approval to this exact (id, conversation, patient,
 *      message body, secure flag). The tool RESPONSE returns only
 *          { status: "pending_approval", id, content_hash, expires_at, ... }
 *      It NEVER returns the token, and never echoes the message body.
 *
 *   2. The orchestrator/operator (a human-gated process with filesystem access
 *      to the 0700 state dir -- the LLM tool channel has no such access) reads
 *      the token from <state>/pending-sends/<id>.json AFTER Gautam confirms the
 *      send over iMessage, then writes the approval marker:
 *          <state>/approved/<id>     containing exactly the token string
 *      This file lives in a dir only the operator/server user can write. Writing
 *      it is the human release action.
 *
 *   3. The LLM calls curogram_send_text again with { approval_id: <id> } (no
 *      token -- it does not have one). The server loads the pending record, loads
 *      the approval marker, and requires:
 *          - marker token === record token        (operator-issued, unguessable)
 *          - record not expired (short TTL)
 *          - record not already used (single-use)
 *          - the live call content hash === record content hash
 *            (same conversation, patient, message body, secure flag)
 *      Only then does the real Curogram send proceed. The record is marked used
 *      so the token cannot be replayed.
 *
 * WHY THE LLM CANNOT SELF-GRANT
 * -----------------------------
 *   - The token is random (crypto), generated server-side, stored only on the
 *     0700 filesystem, and never returned through the tool channel.
 *   - Approval originates from writing a file the LLM tool channel cannot write.
 *   - Even if the LLM guessed an id, it cannot produce the matching token, and
 *     the content hash binds approval to the exact message + recipient, so an
 *     approved id cannot be reused to send different content or to a different
 *     patient/conversation.
 *
 * PHI DISCIPLINE
 * --------------
 *   The message body is hashed (sha-256) for binding but the plaintext body is
 *   NOT persisted to the pending record and NOT logged. Only ids + hash + minimal
 *   metadata are stored.
 */

import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { homedir, userInfo } from "node:os";
import {
  mkdirSync,
  writeFileSync,
  readFileSync,
  existsSync,
  renameSync,
  chmodSync,
  statSync,
  readdirSync,
  unlinkSync,
} from "node:fs";
import { join } from "node:path";

/** Default approval TTL: short window so a stale approval cannot be replayed. */
const DEFAULT_TTL_MS = 15 * 60 * 1000; // 15 minutes

/** Resolve the private state root (XDG_STATE_HOME, else ~/.local/state). */
function stateRoot(): string {
  const xdg = process.env.XDG_STATE_HOME;
  const base =
    xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "state");
  return join(base, "curogram-mcp");
}

function pendingDir(): string {
  return join(stateRoot(), "pending-sends");
}

function approvedDir(): string {
  return join(stateRoot(), "approved");
}

/**
 * Ensure a directory exists and is private (0700, owner-only).
 *
 * `mkdirSync({ mode })` only applies the mode when the dir is freshly created
 * (and even then it's masked by umask). A dir that already exists with
 * group/world bits would leave the secret tokens readable outside the operator
 * boundary, defeating the whole gate. So we ALWAYS chmod to 0700 after ensuring
 * the dir, then verify ownership + perms and fail closed if they aren't private.
 */
function ensureDir0700(dir: string): void {
  mkdirSync(dir, { recursive: true, mode: 0o700 });
  // Force private perms even if the dir pre-existed with looser bits.
  chmodSync(dir, 0o700);
  const st = statSync(dir);
  // Reject any group/world bit (rwx for group or other).
  if ((st.mode & 0o077) !== 0) {
    throw new Error(
      `curogram send-approval: state dir ${dir} is not private (mode ` +
        `${(st.mode & 0o777).toString(8)}); refusing to hold approval tokens ` +
        "in a group/world-accessible directory.",
    );
  }
  // Reject a dir owned by another user (e.g. a planted symlink target).
  if (typeof st.uid === "number" && st.uid !== userInfo().uid) {
    throw new Error(
      `curogram send-approval: state dir ${dir} is owned by another user; ` +
        "refusing to use it for approval tokens.",
    );
  }
}

/**
 * Canonical content hash binding an approval to the exact send. Any change to
 * conversation, patient, message body, or secure flag produces a different
 * hash, so an approval for one send can never release a different one.
 */
export function contentHash(input: {
  conversationId: string;
  patientId: string;
  message: string;
  sendSecurely: boolean;
}): string {
  const h = createHash("sha256");
  // Length-prefix each field so concatenation is unambiguous.
  for (const part of [
    input.conversationId,
    input.patientId,
    input.message,
    input.sendSecurely ? "1" : "0",
  ]) {
    h.update(String(part.length));
    h.update(" ");
    h.update(part);
    h.update(" ");
  }
  return h.digest("hex");
}

/** The on-disk pending-send record. The `token` is operator-only, never returned. */
interface PendingRecord {
  id: string;
  conversation_id: string;
  patient_id: string;
  /** Server-generated, unguessable, single-use. Operator-only, not returned. */
  token: string;
  content_hash: string;
  created_at: string;
  expires_at: string;
  /** Set true once the approval is consumed (single-use). */
  used: boolean;
}

/** What the tool returns to the LLM caller: no token, no message body. */
export interface PendingResponse {
  status: "pending_approval";
  id: string;
  conversation_id: string;
  patient_id: string;
  content_hash: string;
  created_at: string;
  expires_at: string;
  note: string;
}

function recordPath(id: string): string {
  return join(pendingDir(), `${id}.json`);
}

function approvalMarkerPath(id: string): string {
  return join(approvedDir(), id);
}

/** Strict id shape so an id cannot escape the state dir via traversal. */
const ID_RE = /^[a-f0-9]{32}$/;

/**
 * Best-effort prune of records that are used or past expiry (and their matching
 * approval markers). Keeps the state dir from accumulating stale tokens over
 * time. Never throws — pruning failures must not block a legitimate send.
 */
function pruneStale(): void {
  let files: string[];
  try {
    files = readdirSync(pendingDir());
  } catch {
    return;
  }
  const now = Date.now();
  for (const f of files) {
    if (!f.endsWith(".json")) continue;
    const id = f.slice(0, -".json".length);
    if (!ID_RE.test(id)) continue;
    try {
      const rec = JSON.parse(
        readFileSync(recordPath(id), "utf8"),
      ) as PendingRecord;
      const expired = now > Date.parse(rec.expires_at);
      if (!rec.used && !expired) continue;
      unlinkSync(recordPath(id));
      const marker = approvalMarkerPath(id);
      if (existsSync(marker)) unlinkSync(marker);
    } catch {
      // Skip unreadable / racing entries; a later prune retries.
    }
  }
}

/**
 * Create a pending-send request. Writes the record (with the secret token) to
 * the 0700 state dir and returns the LLM-safe response (no token, no body).
 */
export function createPendingSend(input: {
  conversationId: string;
  patientId: string;
  message: string;
  sendSecurely: boolean;
  ttlMs?: number;
}): PendingResponse {
  ensureDir0700(pendingDir());
  ensureDir0700(approvedDir());
  pruneStale();

  const id = randomBytes(16).toString("hex"); // 32 hex chars
  const token = randomBytes(32).toString("hex"); // 256-bit, operator-only
  const hash = contentHash(input);
  const now = new Date();
  const ttl = input.ttlMs ?? DEFAULT_TTL_MS;
  const expiresAt = new Date(now.getTime() + ttl);

  const record: PendingRecord = {
    id,
    conversation_id: input.conversationId,
    patient_id: input.patientId,
    token,
    content_hash: hash,
    created_at: now.toISOString(),
    expires_at: expiresAt.toISOString(),
    used: false,
  };

  // Write atomically (temp + rename) with 0600 so the token is not world/group
  // readable. The body is deliberately absent from the record.
  const tmp = recordPath(`${id}.tmp`);
  writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
  renameSync(tmp, recordPath(id));

  return {
    status: "pending_approval",
    id,
    conversation_id: input.conversationId,
    patient_id: input.patientId,
    content_hash: hash,
    created_at: record.created_at,
    expires_at: record.expires_at,
    note:
      "Send is held pending out-of-band human approval. A human must confirm, " +
      "then the operator releases it. The tool caller cannot self-approve.",
  };
}

export type ApprovalCheck = { ok: true } | { ok: false; reason: string };

function timingSafeStrEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return timingSafeEqual(ab, bb);
}

/**
 * Validate an approval for a pending id against the live send content hash.
 * On success the record is marked used (single-use) before returning ok.
 * Fail-closed on every error path.
 */
export function consumeApproval(
  id: string,
  liveContentHash: string,
): ApprovalCheck {
  if (!ID_RE.test(id)) {
    return { ok: false, reason: "invalid approval_id format" };
  }

  const recPath = recordPath(id);
  if (!existsSync(recPath)) {
    return { ok: false, reason: "no pending request for that approval_id" };
  }

  let record: PendingRecord;
  try {
    record = JSON.parse(readFileSync(recPath, "utf8")) as PendingRecord;
  } catch {
    return { ok: false, reason: "pending record unreadable" };
  }

  if (record.used) {
    return { ok: false, reason: "approval already used (single-use)" };
  }

  if (Date.now() > Date.parse(record.expires_at)) {
    return { ok: false, reason: "approval window expired" };
  }

  // Bind to the exact content: a different message/patient/conversation/secure
  // flag produces a different hash and is refused.
  if (!timingSafeStrEqual(record.content_hash, liveContentHash)) {
    return {
      ok: false,
      reason:
        "content hash mismatch -- the approved request differs from this send " +
        "(message, patient, conversation, or secure flag changed)",
    };
  }

  // The release marker: a file the operator writes containing the secret token.
  const markerPath = approvalMarkerPath(id);
  if (!existsSync(markerPath)) {
    return {
      ok: false,
      reason: "not yet approved (no operator approval marker present)",
    };
  }

  let markerToken: string;
  try {
    markerToken = readFileSync(markerPath, "utf8").trim();
  } catch {
    return { ok: false, reason: "approval marker unreadable" };
  }

  if (!timingSafeStrEqual(markerToken, record.token)) {
    return { ok: false, reason: "approval token mismatch" };
  }

  // Single-use: mark the record used atomically before the send proceeds, so a
  // crash mid-send cannot allow a replay.
  record.used = true;
  try {
    const tmp = recordPath(`${id}.tmp`);
    writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
    renameSync(tmp, recPath);
  } catch (e) {
    return {
      ok: false,
      reason: `could not mark approval used: ${e instanceof Error ? e.message : String(e)}`,
    };
  }

  return { ok: true };
}
