/**
 * src/http/approval-preview.ts (W1) — derive a bounded, sanitized preview of a
 * held tool approval so a remote one-tap approve is no longer blind.
 *
 * Pure + never throws (SEC-M7 discipline, mirroring parseHookEvent). The input
 * is the Claude Code hook `tool_input` — attacker-influenced content arriving on
 * the loopback `/hook/permission` route. It is treated as `unknown`, every field
 * narrowed before use, and every emitted string passed through sanitizeField
 * (strips ASCII control chars incl. ANSI ESC / BEL) and line/byte-capped so a
 * hostile or huge tool_input can never bloat the broadcast / pendingApprovals.
 *
 * Mapping:
 *   Bash                                 → { kind:'command', text }
 *   Edit / Write / MultiEdit / NotebookEdit → { kind:'diff', file }
 *   anything else / malformed input      → null (FE falls back to a name-only bar)
 *
 * SECURITY: this module NEVER runs a command, touches fs, or interprets a path —
 * it only produces display data. Rendering is textContent / renderDiffFile only
 * (public/*), never innerHTML.
 */

import type { ApprovalPreview, DiffFile, DiffHunk, DiffLine } from '../types.js';
import { sanitizeField } from '../session/timeline.js';

// ── bounds (security limits, not user knobs — see plan §"New env vars") ────────

/** Max diff/command lines emitted across the whole preview. */
export const PREVIEW_MAX_LINES = 40;
/** Per-line char cap (matches sanitizeField's default). */
export const PREVIEW_MAX_LINE_LEN = 200;
/** Hard total-byte cap on the emitted preview text (all lines combined). */
export const PREVIEW_MAX_BYTES = 4096;

/** Tools that map to a diff preview (mirrors timeline.ts EDIT_TOOLS semantics). */
const EDIT_TOOLS: ReadonlySet<string> = new Set([
  'Edit',
  'Write',
  'MultiEdit',
  'NotebookEdit',
]);

// ── helpers ────────────────────────────────────────────────────────────────────

/** Narrow an unknown value to a plain object record, or null. */
function asRecord(v: unknown): Record<string, unknown> | null {
  if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
  return v as Record<string, unknown>;
}

/** Sanitize a single line: strip control/ANSI chars (via sanitizeField), cap to
 *  PREVIEW_MAX_LINE_LEN, and report whether the cap actually clipped content (so
 *  a hidden over-long line still flags the preview as truncated). Newlines never
 *  reach here — callers split on '\n' first so structure survives. */
function sanitizeLine(s: string): { text: string; clipped: boolean } {
  // sanitizeField with an effectively-unbounded max = control-strip only (DRY:
  // reuse the SEC-H6 regex), then apply OUR length cap + clip detection here.
  const stripped = sanitizeField(s, Number.MAX_SAFE_INTEGER);
  const text = stripped.slice(0, PREVIEW_MAX_LINE_LEN);
  return { text, clipped: stripped.length > PREVIEW_MAX_LINE_LEN };
}

/** Split a raw multi-line blob into individual (still-raw) lines. */
function splitLines(s: string): string[] {
  return s.split('\n');
}

/** UTF-8 byte length of a string (whole-string). */
function byteLen(s: string): number {
  return Buffer.byteLength(s, 'utf8');
}

/** A budget shared across all emitted lines of one preview. */
interface Budget {
  linesLeft: number;
  bytesLeft: number;
  truncated: boolean;
}

function newBudget(): Budget {
  return { linesLeft: PREVIEW_MAX_LINES, bytesLeft: PREVIEW_MAX_BYTES, truncated: false };
}

/**
 * Truncate `s` to whole characters that fit within `maxBytes` UTF-8 bytes
 * (never splitting a multi-byte codepoint). Returns whether it was clamped.
 */
function clampBytes(s: string, maxBytes: number): { text: string; clamped: boolean } {
  if (byteLen(s) <= maxBytes) return { text: s, clamped: false };
  let out = '';
  let used = 0;
  for (const ch of s) {
    const b = byteLen(ch);
    if (used + b > maxBytes) break;
    out += ch;
    used += b;
  }
  return { text: out, clamped: true };
}

// ── Bash → command preview ───────────────────────────────────────────────────────

function deriveCommand(input: Record<string, unknown>): ApprovalPreview | null {
  const command = input['command'];
  if (typeof command !== 'string') return null;

  const rawLines = splitLines(command);
  const lineCountTruncated = rawLines.length > PREVIEW_MAX_LINES;
  let anyClipped = false;
  const joined = rawLines
    .slice(0, PREVIEW_MAX_LINES)
    .map((l) => {
      const r = sanitizeLine(l);
      if (r.clipped) anyClipped = true;
      return r.text;
    })
    .join('\n');
  const { text, clamped } = clampBytes(joined, PREVIEW_MAX_BYTES);

  const truncated = lineCountTruncated || anyClipped || clamped;
  return { kind: 'command', text, ...(truncated ? { truncated: true } : {}) };
}

// ── Edit-family → diff preview ───────────────────────────────────────────────────

/** One hunk's worth of source lines (still raw, unsanitized). */
interface HunkSpec {
  removed: string[];
  added: string[];
}

/** Pick a display path from the tool_input, or '' when none is present. */
function pickPath(input: Record<string, unknown>): string {
  const fp = input['file_path'];
  if (typeof fp === 'string') return fp;
  const np = input['notebook_path'];
  if (typeof np === 'string') return np;
  return '';
}

/** Turn an {old_string,new_string} object into a HunkSpec. Requires BOTH fields
 *  to be strings (an Edit missing old_string / new_string → null → drop). */
function toHunkSpec(v: unknown): HunkSpec | null {
  const rec = asRecord(v);
  if (rec === null) return null;
  const oldStr = rec['old_string'];
  const newStr = rec['new_string'];
  if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
  return { removed: splitLines(oldStr), added: splitLines(newStr) };
}

/** Emit a bounded array of DiffLines from raw source lines, honouring the budget. */
function emitLines(
  kind: DiffLine['kind'],
  rawLines: readonly string[],
  budget: Budget,
): DiffLine[] {
  const out: DiffLine[] = [];
  for (const raw of rawLines) {
    if (budget.linesLeft <= 0) {
      budget.truncated = true;
      break;
    }
    const { text, clipped } = sanitizeLine(raw);
    if (clipped) budget.truncated = true;
    const b = byteLen(text);
    if (b > budget.bytesLeft) {
      budget.truncated = true;
      break;
    }
    out.push({ kind, text });
    budget.linesLeft -= 1;
    budget.bytesLeft -= b;
  }
  return out;
}

/** Build a synthetic DiffFile preview from one-or-more hunk specs. */
function buildDiffPreview(rawPath: string, specs: readonly HunkSpec[]): ApprovalPreview {
  const path = sanitizeLine(rawPath).text;
  let addedTotal = 0;
  let removedTotal = 0;
  for (const s of specs) {
    addedTotal += s.added.length;
    removedTotal += s.removed.length;
  }

  const budget = newBudget();
  const hunks: DiffHunk[] = [];
  for (let i = 0; i < specs.length; i++) {
    const spec = specs[i]!;
    const lines = [
      ...emitLines('removed', spec.removed, budget),
      ...emitLines('added', spec.added, budget),
    ];
    if (lines.length > 0) {
      hunks.push({ header: specs.length > 1 ? `edit ${i + 1}` : '', lines });
    }
    if (budget.truncated) break;
  }

  const file: DiffFile = {
    oldPath: path,
    newPath: path,
    status: removedTotal === 0 ? 'added' : 'modified',
    added: addedTotal,
    removed: removedTotal,
    binary: false,
    hunks,
  };
  return { kind: 'diff', file, ...(budget.truncated ? { truncated: true } : {}) };
}

function deriveDiff(toolName: string, input: Record<string, unknown>): ApprovalPreview | null {
  const path = pickPath(input);

  if (toolName === 'MultiEdit') {
    const edits = input['edits'];
    if (!Array.isArray(edits)) return null;
    const specs = edits.map(toHunkSpec).filter((s): s is HunkSpec => s !== null);
    if (specs.length === 0) return null;
    return buildDiffPreview(path, specs);
  }

  if (toolName === 'Write') {
    const content = input['content'];
    if (typeof content !== 'string') return null;
    return buildDiffPreview(path, [{ removed: [], added: splitLines(content) }]);
  }

  if (toolName === 'NotebookEdit') {
    const source = input['new_source'];
    if (typeof source !== 'string') return null;
    return buildDiffPreview(path, [{ removed: [], added: splitLines(source) }]);
  }

  // Edit: a single {old_string,new_string} hunk.
  const spec = toHunkSpec(input);
  if (spec === null) return null;
  return buildDiffPreview(path, [spec]);
}

// ── public entry point ───────────────────────────────────────────────────────────

/**
 * Derive a bounded approval preview from a hook tool_input, or null when the
 * tool is not previewable or the input is malformed. NEVER throws (SEC-M7).
 */
export function deriveApprovalPreview(
  toolName: string | undefined,
  toolInput: unknown,
): ApprovalPreview | null {
  try {
    if (toolName === undefined) return null;
    const input = asRecord(toolInput);
    if (input === null) return null;
    if (toolName === 'Bash') return deriveCommand(input);
    if (EDIT_TOOLS.has(toolName)) return deriveDiff(toolName, input);
    return null;
  } catch {
    // SEC-M7: any unexpected shape must degrade to "no preview", never crash.
    return null;
  }
}
