/**
 * Local Analysis — On-device legal review for confidential documents.
 *
 * v2 (2026-04-27): clause-aware map-reduce analysis.
 *
 * The previous version sent the first 8 K characters of a document to a
 * single LLM call and asked the model both to (a) extract verbatim quotes
 * and (b) reason about every clause. Small models (Gemma 2 2B, Phi-3 3B,
 * Llama 3.2 3B) cut both corners: they emitted "..." quote placeholders,
 * tagged everything "info" severity, and produced generic boilerplate
 * concerns that could apply to any contract.
 *
 * v2 fixes the design so a 2-3B model can produce useful triage:
 *
 *   1. **Clause-boundary chunking.** We segment the document by
 *      matching `^N. <Title>` patterns, producing one chunk per clause
 *      (≈21 chunks for a typical JV agreement). The model sees full
 *      clause text, not a 18% truncation.
 *
 *   2. **Pre-extracted text.** The chunker pulls the verbatim text
 *      from the document and hands it to the model. The model never
 *      has to "quote" — it only analyses what it was given. Kills
 *      the "..." placeholder failure.
 *
 *   3. **Per-clause prompts.** One LLM call per clause keeps each
 *      input small and focused. Small models excel at narrow tasks.
 *
 *   4. **Force-structured concerns.** Each concern must reference a
 *      specific party / defined term / dollar amount or it's stripped.
 *
 *   5. **Severity budget.** A final synthesis pass re-allocates
 *      severities — the model can't escape into "everything is info".
 *
 *   6. **Synthesis pass.** A final LLM call merges per-clause findings
 *      into top-N risks, executive summary, and recommendations.
 *
 * Cost: $0 per document (local inference).
 */

import { config } from '../config.js';
import type { ClawProfile, WatchmanResult, WatchmanDocumentType } from './types.js';
import type { PrecedentBoard, PrecedentMatch } from './precedent-board.js';
import { readerTemplate } from './reader-templates.js';

// ── Result Types ─────────────────────────────────────────────────────────

export interface LocalAnalysisResult {
  /** Plain-language document summary */
  summary: string;
  /** Detected document type (NDA, contract, etc.) */
  documentType: string;
  /** Key clauses identified */
  clauses: ClauseAnalysis[];
  /** Risk flags with severity */
  risks: RiskItem[];
  /** Actionable recommendations */
  recommendations: string[];
  /** Always present — reminds users this was local analysis */
  confidenceNote: string;
  /** Model used for analysis */
  model: string;
  /** Lighthouse Phase 2: PREC-ids surfaced during the per-clause sweep
   *  (deduped across clauses). Empty when no precedent board was supplied. */
  precedentsConsulted?: string[];
  /** Lighthouse Phase 2: number of findings the grounding pass stripped
   *  for lacking document-text anchorage. */
  unanchoredStripped?: number;
  /** v3.4: true if the synthesis emitted the client's company name and
   *  the post-process had to strip it (replacing with "the client"). The
   *  output is sanitised either way; this flag exists for the audit trail. */
  clientNameLeaked?: boolean;
}

export interface ClauseAnalysis {
  title: string;
  /** Exact clause text (extracted by the chunker, not generated by the model) */
  text: string;
  /** What to watch for */
  concern: string;
  severity: 'info' | 'minor' | 'major' | 'critical';
}

export interface RiskItem {
  description: string;
  severity: 'low' | 'medium' | 'high' | 'critical';
  /** Where in the document */
  citation: string;
}

// ── Clause Chunking ──────────────────────────────────────────────────────

interface ClauseChunk {
  /** "1", "2", … "21" */
  number: string;
  /** "Definitions and interpretation" */
  title: string;
  /** Verbatim text of the clause body, extracted from the parsed document. */
  body: string;
}

/**
 * Chunk a document by clause boundaries.
 *
 * Two-pass strategy:
 *
 *   PASS 1 — line-anchored (the common case for negotiated drafts):
 *     Lines matching `<N>. <Title>` (e.g. "1. Definitions and interpretation",
 *     "9. Cash Calls and Dilution") are treated as clause headers and
 *     everything from one such header to the next becomes a clause body.
 *
 *   PASS 2 — inline-numbered fallback (for CUAD/PDF/OCR/copy-paste text):
 *     When pass 1 finds nothing, scan for inline patterns like
 *     `... members. 19. Capital Contributions ...` — a number-dot-space-CapTitle
 *     embedded mid-paragraph. This is the format real-world contracts arrive
 *     in when extracted from PDFs or pasted from a single-paragraph source.
 *
 *   PASS 3 — final fallback:
 *     If both fail (an NDA-style narrative with no numbering at all), the
 *     whole document becomes one chunk so the analyser still gets to run.
 *
 * Pass 2 is the load-bearing addition. Without it, the Reader's claim of
 * ~22 calls per document silently collapses to 1 call on any flat-paragraph
 * input. The lighthouse eval at evals/jv/EVAL_REPORT.md documents the bug
 * this fix closes.
 */
function chunkByClauseBoundaries(text: string): ClauseChunk[] {
  // ── Pass 1: line-anchored headers ──────────────────────────────────────
  const lineAnchored = chunkLineAnchored(text);
  if (lineAnchored.length > 0) return lineAnchored;

  // ── Pass 2: inline-numbered (real-world flat-paragraph contracts) ──────
  const inlineNumbered = chunkInlineNumbered(text);
  if (inlineNumbered.length > 0) return inlineNumbered;

  // ── Pass 3: whole document as one chunk ────────────────────────────────
  return [{ number: '1', title: 'Document', body: text.trim() }];
}

/** Pass 1: clause headers on their own line. The traditional negotiated-doc case. */
function chunkLineAnchored(text: string): ClauseChunk[] {
  const lines = text.split('\n');
  const chunks: ClauseChunk[] = [];
  let current: { number: string; title: string; body: string[] } | null = null;

  for (const line of lines) {
    const trimmed = line.trim();
    // Match "1.", "1.0.", "21." at line start with title capitalised
    const m = /^(\d{1,2})\.\s+([A-Z][^.]{2,80})$/.exec(trimmed);
    if (m) {
      if (current) {
        chunks.push({
          number: current.number,
          title: current.title,
          body: current.body.join('\n').trim(),
        });
      }
      current = { number: m[1], title: m[2], body: [] };
    } else if (current) {
      current.body.push(line);
    }
  }
  if (current) {
    chunks.push({
      number: current.number,
      title: current.title,
      body: current.body.join('\n').trim(),
    });
  }
  return chunks;
}

/**
 * Pass 2: inline-numbered split. Scans for clause-boundary markers embedded
 * in flowing prose (the CUAD/PDF-extract case). Recognises three patterns:
 *
 *   (a) Period-style:    `... members. 19. Capital Contributions ...`
 *   (b) Paren-style:     `... activities. 7) Duties of COMPANY ...`
 *   (c) Article-style:   `... above. Article 3. Governing Law ...`
 *
 * Returns chunks only if we find at least 3 boundaries — fewer than that is
 * almost certainly noise (article numbers in the recitals, dates, list items).
 * If pattern (a) finds enough boundaries, we use it; otherwise we try (b),
 * then (c). We do not mix patterns within one document.
 *
 * Title extraction: the heading text may appear either BEFORE the number
 * (BorrowMoney/CUAD style: "Withdrawal of Capital 17. No Member will...")
 * or AFTER (Lavern-template style: "1. Definitions and interpretation").
 * We grab whichever side looks more heading-like (capitalised, short).
 */
function chunkInlineNumbered(text: string): ClauseChunk[] {
  // Try patterns in priority order. First one with ≥3 monotonic boundaries wins.
  const patterns: Array<{ regex: RegExp; markerLen: (m: RegExpExecArray) => number }> = [
    // (a) "N. Title" — period style, must follow sentence end / newline
    {
      regex: /(?:^|[.!?]["')\]]?\s+|\n\s*)(\d{1,2})\.\s+([A-Z])/g,
      markerLen: (m) => m[0].length - (m[0].lastIndexOf(m[1])) /* offset adjusted below */,
    },
    // (b) "N) Title" — paren style (Sibannac et al)
    {
      regex: /(?:^|[.!?]["')\]]?\s+|\n\s*)(\d{1,2})\)\s+([A-Z])/g,
      markerLen: (m) => m[0].length,
    },
    // (c) "Article N." / "Section N." / "ARTICLE N." style (Veoneer et al)
    {
      regex: /(?:^|[.!?]["')\]]?\s+|\n\s*)(?:Article|ARTICLE|Section|SECTION)\s+(\d{1,2})\.([A-Z]|\s+[A-Z])/g,
      markerLen: (m) => m[0].length,
    },
  ];

  for (const { regex } of patterns) {
    const matches: Array<{ index: number; number: string }> = [];
    let m: RegExpExecArray | null;
    while ((m = regex.exec(text)) !== null) {
      // Position the boundary at the start of the number, not the preceding punctuation.
      const numIdx = m.index + m[0].indexOf(m[1]);
      matches.push({ index: numIdx, number: m[1] });
    }

    if (matches.length < 3) continue;

    // Most numbers should be increasing — guards against false-positive runs
    // ("5%", "10 days", "2020", etc.) that happen to look like clause numbers.
    let increasing = 0;
    for (let i = 1; i < matches.length; i++) {
      if (Number(matches[i].number) > Number(matches[i - 1].number)) increasing++;
    }
    if (increasing < matches.length * 0.6) continue;

    // Build chunks
    const chunks: ClauseChunk[] = [];
    for (let i = 0; i < matches.length; i++) {
      const start = matches[i].index;
      const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
      const body = text.slice(start, end).trim();
      if (body.length < 40) continue;
      // Title: try preceding 80 chars first (CUAD-style "Heading 17. body…");
      // fall back to first 60 chars after the number marker (Lavern-style).
      const title = extractClauseTitle(text, matches[i].index, body);
      chunks.push({ number: matches[i].number, title, body });
    }
    if (chunks.length >= 3) return chunks;
  }
  return [];
}

/** Best-effort title extraction. Looks before the number first (heading-style
 *  layout), then after (numbered-title style). Returns "Clause N" if neither
 *  produces something heading-shaped. */
function extractClauseTitle(text: string, numberIdx: number, body: string): string {
  // Look at up to 80 chars before the number for a heading word/phrase
  const before = text.slice(Math.max(0, numberIdx - 80), numberIdx).trim();
  const beforeWords = before.match(/(?:[A-Z][A-Za-z]+(?:\s+(?:of|and|the|in|for|to|on|with|&))?\s+)*[A-Z][A-Za-z]+\s*$/);
  if (beforeWords && beforeWords[0].trim().length >= 3 && beforeWords[0].trim().length <= 60) {
    return beforeWords[0].trim();
  }
  // Otherwise, take the first short heading-like phrase from the body
  const afterMatch = body.match(/^\d{1,2}[\.\)]\s+([A-Z][A-Za-z0-9 ,'&/\-]{2,60}?)(?=[.\n]|\s{2}|\s+[a-z]|$)/);
  if (afterMatch) return afterMatch[1].trim();
  const articleMatch = body.match(/^(?:Article|ARTICLE|Section|SECTION)\s+\d+\.\s*([A-Z][A-Za-z0-9 ,'&/\-]{2,60}?)(?=[.\n]|\s{2}|$)/);
  if (articleMatch) return articleMatch[1].trim();
  return `Clause`;
}

// ── Prompts ──────────────────────────────────────────────────────────────

const PER_CLAUSE_PROMPT = `You are a senior legal-risk analyst. You will receive ONE clause from a contract at a time. Your job: identify what could go wrong for the CLIENT under this specific clause.

Rules — failure to follow makes your output unusable:

1. Every "concern" you raise must reference at least ONE of:
     (a) a specific defined term from this contract (e.g. "Operator", "Cure Period", "Reserved Matter")
     (b) a specific party by name (e.g. "Hartogen", "Cobaridge")
     (c) a specific dollar amount, percentage, or time period from the clause text

   Concerns that say only "ensure clarity" or "verify terms are clear" are GENERIC and FORBIDDEN.

2. Severity rules — use the full scale, do not default to "info":
     - "critical" → potential loss > 10% of project value, or unrecoverable rights forfeiture
     - "major"    → recoverable but materially adverse outcome, or onerous mechanic
     - "minor"    → mild drafting risk, ambiguity worth flagging
     - "info"     → background / definitional only (use sparingly — most clauses have real risk)

3. If the clause is purely definitional or boilerplate, output an empty concerns array.

4. The "operative_text" field must be a verbatim 1-3 sentence excerpt FROM THE CLAUSE I gave you. Do NOT write "...". Do NOT paraphrase. Pick the most material sentence(s).

Output ONLY a JSON object with this exact structure:
{
  "clauseRiskSummary": "string (1-2 sentences specific to THIS clause, not generic)",
  "operative_text": "string (verbatim excerpt, 1-3 sentences, from this clause)",
  "concerns": [
    {
      "text": "string (specific concern referencing defined term / party / number)",
      "severity": "info" | "minor" | "major" | "critical",
      "references": ["string"]   // list the defined terms / amounts / parties this references
    }
  ],
  "favoursWhom": "operator" | "non-operator" | "neutral"
}`;

const SYNTHESIS_PROMPT = `You are the senior partner closing out a contract review. You have just received per-clause analyses from your team. Produce the executive summary.

**CRITICAL FACTUAL DISCIPLINE — read this twice:**

The CLIENT block you will receive (company, industry, jurisdiction, concerns) tells you HOW STRICTLY to read the document. It is the client's stance, not the document's facts. The summary, documentType, and risks must describe what is in the DOCUMENT — not what the client does or where the client is.

- If the client is in mining but the document is a software licence, the summary describes a software licence.
- If the client is in NSW but the document is governed by Delaware law, the summary says Delaware.
- If the per-clause findings name "Bravatek and Sibannac" or "Veoneer and Nissin", those are the parties — NOT the client company.
- Parties, jurisdiction, project type, and document type are EVIDENCE from the document, not assumptions from the profile.

Use the CLIENT block ONLY to choose the risk-appetite framing (conservative vs aggressive) and to weight concerns that align with the client's stated interests.

Rules:

1. The "summary" field is 2-3 sentences. Name BOTH PARTIES exactly as they appear in the per-clause findings. Name the jurisdiction as it appears in the findings (governing-law clauses or party addresses). Name the actual subject of the contract (its purpose, not the client's industry). End with the single biggest exposure for the client. If the per-clause findings do not contain enough information to name a party or jurisdiction, write "(party/jurisdiction not stated)" rather than guessing.

2. The "documentType" must be specific to the DOCUMENT (e.g. "Joint Venture Agreement (Florida law, contractual JV)", "Strategic Alliance / Sales Channel Agreement (Texas law)", "JV Amendment and Termination Deed"). Not "Contract". Not "Mining JV" unless the document itself is about mining.

3. The "topRisks" array contains AT LEAST 3 and AT MOST 6 risks, drawn from the highest-severity concerns in the per-clause analyses. Each risk MUST reference a clause number that appears in the per-clause findings. Do not invent clause numbers.

4. The "recommendations" array contains 3-5 actions the client should take BEFORE the next board meeting. Each starts with an imperative verb ("Issue", "Demand", "Audit", "Reject", "Notify"). No "review and consider" — that is not advice.

Output ONLY a JSON object:
{
  "summary": "string",
  "documentType": "string",
  "topRisks": [
    {
      "description": "string",
      "severity": "low" | "medium" | "high" | "critical",
      "citation": "string (clause number, e.g. 'cl 9.3' or 'cl 18.2')"
    }
  ],
  "recommendations": ["string"]
}`;

// ── HTTP Helper ──────────────────────────────────────────────────────────

interface OllamaConfig {
  baseUrl: string;
  modelName: string;
  timeoutMs: number;
}

function resolveOllamaConfig(): OllamaConfig {
  const modelName =
    config.claw.localAnalysisModel ||
    config.claw.localModel ||
    config.local.defaultModel;

  if (!modelName) {
    throw new Error(
      'No local model configured. Set LAVERN_LOCAL_DEFAULT_MODEL (preferred) or LAVERN_LOCAL_MODEL in .env',
    );
  }

  const baseUrl = (config.claw.localModelUrl || config.local.baseUrl).replace(/\/$/, '');
  const timeoutMs = config.claw.localTimeoutMs;
  return { baseUrl, modelName, timeoutMs };
}

async function ollamaJsonChat(
  cfg: OllamaConfig,
  systemPrompt: string,
  userMessage: string,
  maxTokens = 1500,
): Promise<unknown> {
  const response = await fetch(`${cfg.baseUrl}/v1/chat/completions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: cfg.modelName,
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userMessage },
      ],
      temperature: 0.1,
      max_tokens: maxTokens,
      response_format: { type: 'json_object' },
    }),
    signal: AbortSignal.timeout(cfg.timeoutMs),
  });

  if (!response.ok) {
    const text = await response.text().catch(() => '');
    throw new Error(`Local model returned ${response.status}: ${text.slice(0, 200)}`);
  }

  const data = await response.json() as { choices?: Array<{ message: { content: string } }> };
  const content = data.choices?.[0]?.message?.content;
  if (!content) throw new Error('Local model returned empty response');

  return safeJsonParse(content);
}

function safeJsonParse(content: string): unknown {
  try { return JSON.parse(content); } catch { /* try fallbacks */ }
  // markdown-fenced JSON
  const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fenced) {
    try { return JSON.parse(fenced[1]); } catch { /* try last fallback */ }
  }
  // first balanced object
  const obj = content.match(/\{[\s\S]*\}/);
  if (obj) {
    try { return JSON.parse(obj[0]); } catch { /* fall through */ }
  }
  throw new Error('Local model returned malformed JSON');
}

// ── Per-Clause Analysis ──────────────────────────────────────────────────

interface ClauseFinding {
  clauseNumber: string;
  clauseTitle: string;
  clauseRiskSummary: string;
  operativeText: string;
  concerns: Array<{
    text: string;
    severity: 'info' | 'minor' | 'major' | 'critical';
    references: string[];
    /** Phase 2: Precedent ID that informed or was reconciled with this concern. */
    precedentMatch?: string;
    /** Phase 2: Set true by the grounding pass when the evidence cannot be
     *  anchored back to clause text. Stripped from the final result. */
    unanchored?: boolean;
  }>;
  favoursWhom: 'operator' | 'non-operator' | 'neutral';
  /** Phase 2: PREC-ids the model received as context for this clause. */
  precedentsInjected: string[];
}

/**
 * Build a precedent-context block for injection into a per-clause prompt.
 * Empty string when no relevant precedents — keeps prompt size minimal.
 *
 * Phase 5: confirmed precedents are tagged with [CONFIRMED] and listed
 * first; tentative precedents follow with [TENTATIVE]. The Reader is
 * told to weight confirmed positions higher than tentative ones.
 */
function precedentContextBlock(matches: PrecedentMatch[]): string {
  if (matches.length === 0) return '';
  // Sort: confirmed precedents first (the firm has settled on this position),
  // then by relevance score within each tier.
  const sorted = [...matches].sort((a, b) => {
    const aConf = (a.entry.status === 'confirmed') ? 1 : 0;
    const bConf = (b.entry.status === 'confirmed') ? 1 : 0;
    if (aConf !== bConf) return bConf - aConf;
    return b.relevanceScore - a.relevanceScore;
  });
  const lines = sorted.slice(0, 3).map(m => {
    const status = m.entry.status === 'confirmed' ? '[CONFIRMED]' : '[TENTATIVE]';
    const desc = m.entry.description.replace(/[\x00-\x1f]/g, ' ').slice(0, 180);
    return `- ${status} [${m.entry.id}] ${m.entry.patternName} — ${desc} (effectiveness ${(m.entry.effectivenessScore * 100).toFixed(0)}%, seen ${m.entry.timesUsed}x)`;
  });
  return [
    '',
    'PRIOR FIRM POSITIONS ON SIMILAR CLAUSES (advisory — the live document outranks):',
    ...lines,
    '[CONFIRMED] = the firm has settled on this position across multiple matters; weight it heavily unless the document\'s language plainly contradicts.',
    '[TENTATIVE] = the firm has flagged this pattern but not confirmed; treat as a hypothesis to test against the document.',
    'Reconcile your judgment with the firm\'s prior positions where the document supports it. If the document contains language INCONSISTENT with a prior position, trust the document and explain the divergence in your concern.',
    '',
  ].join('\n');
}

async function analyseClause(
  cfg: OllamaConfig,
  chunk: ClauseChunk,
  profile: ClawProfile,
  precedents: PrecedentMatch[] = [],
  /** Phase 6: document type drives template selection. Defaults to the
   *  generic template for unknown / legacy callers. */
  documentType: WatchmanDocumentType = 'other',
): Promise<ClauseFinding> {
  const precedentBlock = precedentContextBlock(precedents);
  // Phase 6: pick the per-clause prompt that matches the document type.
  // 'jv', 'nda', 'employment', 'lease', 'loan', 'saas', 'policy' each get a
  // focused prompt; 'other' falls through to the generic legal-risk prompt.
  const systemPrompt = readerTemplate(documentType);
  const userMessage = [
    `CLIENT: ${profile.company} (${profile.industry}, ${profile.jurisdiction})`,
    `CLIENT'S CONCERNS: ${profile.concerns.join(', ')}`,
    `DOCUMENT TYPE: ${documentType}`,
    `CLIENT'S ROLE: ${profile.preferences?.riskAppetite === 'aggressive' ? 'sophisticated counterparty' : 'risk-conscious participant'}`,
    precedentBlock,
    `=== CLAUSE ${chunk.number}: ${chunk.title} ===`,
    chunk.body,
    `=== END CLAUSE ${chunk.number} ===`,
    '',
    'Now produce your JSON analysis of THIS clause only.',
  ].join('\n');

  const raw = await ollamaJsonChat(cfg, systemPrompt, userMessage, 1200) as Record<string, unknown>;
  const concerns = Array.isArray(raw.concerns) ? raw.concerns : [];

  // Light heuristic: if the model's text mentions a precedent ID we passed
  // in (e.g., "PREC-ab12cd34"), tag the concern with that match.
  const knownIds = new Set(precedents.map(p => p.entry.id));
  function detectPrecedentMatch(text: string): string | undefined {
    if (knownIds.size === 0) return undefined;
    for (const id of knownIds) {
      if (text.includes(id)) return id;
    }
    return undefined;
  }

  return {
    clauseNumber: chunk.number,
    clauseTitle: chunk.title,
    clauseRiskSummary: typeof raw.clauseRiskSummary === 'string' ? raw.clauseRiskSummary : '',
    operativeText: typeof raw.operative_text === 'string' && raw.operative_text.length > 5
      ? raw.operative_text
      : chunk.body.slice(0, 400),
    concerns: concerns.map(c => {
      const cc = c as Record<string, unknown>;
      const text = typeof cc.text === 'string' ? cc.text : '';
      return {
        text,
        severity: validateSeverity(typeof cc.severity === 'string' ? cc.severity : undefined, 'minor'),
        references: Array.isArray(cc.references) ? cc.references.filter((r): r is string => typeof r === 'string') : [],
        precedentMatch: detectPrecedentMatch(text),
      };
    }).filter(c => c.text.length > 10 && isSpecificConcern(c.text, c.references)),
    favoursWhom: validateFavours(typeof raw.favoursWhom === 'string' ? raw.favoursWhom : undefined),
    precedentsInjected: precedents.map(p => p.entry.id),
  };
}

// ── Grounding Pass (Phase 2) ─────────────────────────────────────────────

/**
 * Strip findings whose evidence cannot be anchored back to clause text.
 *
 * Cheap deterministic check first: does the concern's text reference a
 * clause number, defined term, dollar amount, or party name that appears
 * in the clause body? If yes, anchored. If no, mark unanchored.
 *
 * For ambiguous cases, an optional LLM verification pass could be added —
 * but the deterministic pass alone catches the major class of hallucinated
 * concerns ("ensure clarity") that local Gemma sometimes emits.
 *
 * Returns the count of unanchored findings (which are marked, not removed
 * from the audit trail) so the deliverable can report transparency.
 */
function groundingPass(findings: ClauseFinding[]): number {
  let stripped = 0;
  for (const finding of findings) {
    const clauseBodyLower = finding.operativeText.toLowerCase();
    for (const concern of finding.concerns) {
      const tokens = concern.text.match(/\b(?:cl(?:ause)?\s*\d+(?:\.\d+)?|\$[\d,]+|\d+%|\b\d{4}\b|\b\d+\s*(?:days?|months?|years?)\b)/gi) ?? [];
      const refsAnchored = concern.references.some(r => clauseBodyLower.includes(r.toLowerCase()));
      const tokenAnchored = tokens.some(t => clauseBodyLower.includes(t.toLowerCase()));
      if (!refsAnchored && !tokenAnchored && concern.references.length === 0) {
        // No anchor — mark unanchored so synthesis can deprioritise.
        concern.unanchored = true;
        stripped++;
      }
    }
  }
  return stripped;
}

/**
 * Reject concerns that are generic boilerplate. A concern is "specific"
 * iff it references at least one defined term / party / number, OR contains
 * a numeric/dollar/percentage token in the text itself.
 */
function isSpecificConcern(text: string, references: string[]): boolean {
  if (references.length > 0) return true;
  // Allow concerns that mention a clause number, dollar amount, percent, party name, or year
  if (/\bcl(?:ause)?\s*\d/i.test(text)) return true;
  if (/\$\d|%|\b\d{4}\b|\b\d+\s*(?:days?|months?|years?)\b/i.test(text)) return true;
  if (/\b(?:Hartogen|Cobaridge|Operator|Participant|Cure Period|Sole Risk|Reserved Matter|Force Majeure)\b/i.test(text)) return true;
  return false;
}

// ── Synthesis ────────────────────────────────────────────────────────────

/**
 * Score per-clause findings for ranking before synthesis on long documents.
 *
 * Heuristic — no LLM. The score blends:
 *   - max severity of any concern in the clause (critical=4 .. info=1)
 *   - concern count (more concerns → more substantive clause)
 *   - favours-against-client bonus (favours: 'non-operator' for a JV review)
 *
 * Returns the top N findings sorted by score desc. Used by `synthesise` when
 * the document is long enough that feeding every clause to the small model
 * causes synthesis collapse (see v2 eval doc 1: 28 findings → 1 mega-risk).
 */
function rankFindingsForSynthesis(findings: ClauseFinding[], n: number): ClauseFinding[] {
  const scored = findings.map(f => {
    const maxSeverity = f.concerns.reduce(
      (acc, c) => Math.max(acc, severityRank(c.severity)),
      0,
    );
    const concernCount = f.concerns.length;
    const favoursAgainst = f.favoursWhom === 'operator' ? 0 : f.favoursWhom === 'non-operator' ? 1 : 0.3;
    // Severity weighted heavily; concern count is a tie-breaker
    const score = maxSeverity * 10 + Math.min(concernCount, 5) + favoursAgainst;
    return { f, score };
  });
  scored.sort((a, b) => b.score - a.score);
  return scored.slice(0, n).map(s => s.f);
}

/**
 * Programmatic party-name extraction from per-clause findings.
 *
 * v3.2 (Path A): on gemma2:2b the synthesis prompt cannot reliably override
 * the model's training-data memorisation — it pulls memorised CUAD-dataset
 * party names ("Bravatek", "Veoneer and Nissin") even when explicitly told
 * not to. The structural fix: extract candidate party names from the
 * per-clause findings DETERMINISTICALLY and inject them as a closed
 * vocabulary the synthesis prompt must constrain to.
 *
 * v3.3 (regex tightening): v3.2 traded the training-memorisation problem for
 * a regex-artifact problem on 3/10 docs (the extractor's `Co` corporate
 * suffix greedy-matched "Co" as the first 2 letters of "Contributions",
 * "Company"; short acronyms picked up state codes like "AZ" / "NJ" and
 * payment terms like "NET" as candidate parties). v3.3 closes this with
 * four deterministic regex tightenings:
 *
 *   1. Corporate-suffix `Co` requires a true word-boundary after — NOT
 *      "Co" in "Contributions". The regex matches `Co\b` only when
 *      followed by space, punctuation, or end-of-string.
 *   2. STOPLIST expanded with all 50 US state abbreviations + common
 *      international codes + payment-term tokens (NET, NETD, COD).
 *   3. Short acronyms (3-4 chars) must appear ≥ 2 times in the corpus.
 *      Single-occurrence short acronyms are almost always noise.
 *   4. A "single-common-word + corporate-suffix" filter — candidates like
 *      "Capital Co" / "Member Co" / "Exchange Co" are dropped unless they
 *      also appear ≥ 2 times. Real party names like "BISYS Group" or
 *      "United National Bancorp" survive because they're multi-word
 *      or use stronger suffixes.
 *
 * Heuristic — no LLM. Mines four patterns:
 *   - Quoted defined terms in operativeText / concern text  ("Member", "the Operator")
 *   - Corporate-suffix proper nouns                          ("BorrowMoney.com Inc.", "United National Bancorp")
 *   - All-caps acronyms 2-6 chars                            ("TKCI", "VNBJ")
 *   - Filename-derived hints (only when filename is informative)
 */
function extractPartyNames(
  findings: ClauseFinding[],
  filename: string,
  profile: ClawProfile,
): string[] {
  // Mine the corpus we trust: model-quoted text from per-clause findings.
  const corpus = findings.flatMap(f => [
    f.clauseTitle,
    f.clauseRiskSummary,
    f.operativeText,
    ...f.concerns.map(c => c.text),
  ]).join('\n');

  // Pattern 1: quoted defined terms — "Member", "the Operator", "COMPANY"
  const quoted: string[] = [];
  for (const m of corpus.matchAll(/["']([A-Z][A-Za-z][A-Za-z'\-]{1,40}(?:\s+[A-Z][A-Za-z'\-]{1,40}){0,4})["']/g)) {
    quoted.push(m[1].trim());
  }

  // Pattern 2: corporate-suffix proper nouns (most reliable signal of a party).
  //
  // v3.3 change: the suffix list is split into "strong" suffixes (Inc, Corp,
  // LLC, etc. — long enough that there's no ambiguity) and "weak" suffixes
  // (Co, Group, Bank — which can be the first chars of other words). The
  // weak suffixes require a true word boundary after them (period, comma,
  // end-of-string, or punctuation) to count. "Capital Contributions" no
  // longer becomes "Capital Co".
  const corp: string[] = [];
  const corpRe = /\b([A-Z][A-Za-z0-9'\-&.]{1,}(?:\s+(?:de|of|and|the|&)\s+)?(?:\s+[A-Z][A-Za-z0-9'\-&.]{1,}){0,5}\s+(?:Inc|Corp|Corporation|LLC|LLP|Ltd|Limited|Company|Holdings|Industries|Solutions|Technologies|Partners|Trust|Foundation|Bancorp|Services|Systems|Pty|GmbH|S\.A\.|AG|N\.V\.|B\.V\.|S\.p\.A\.)\.?)\b/g;
  // Weak suffixes that need stricter boundaries (Co, Group, Bank can appear
  // as prefixes of common nouns: "Combined", "Grouping", "Banking"). Require
  // the suffix to be followed by punctuation, end-of-line, or sentence-end.
  const corpWeakRe = /\b([A-Z][A-Za-z0-9'\-&.]{1,}(?:\s+(?:de|of|and|the|&)\s+)?(?:\s+[A-Z][A-Za-z0-9'\-&.]{1,}){0,5}\s+(?:Co|Group|Bank|AB)\.?)(?=[,.;:)"'\s]|$)(?!\w)/g;
  for (const m of corpus.matchAll(corpRe)) {
    corp.push(m[1].replace(/\s+/g, ' ').trim());
  }
  for (const m of corpus.matchAll(corpWeakRe)) {
    const candidate = m[1].replace(/\s+/g, ' ').trim();
    // v3.3 fix #4: single-common-word + weak-suffix is almost always a
    // regex artifact (Capital Co, Member Co, Exchange Co, Term Co).
    // Drop unless the candidate has multiple capitalised words OR appears
    // to be a multi-word entity. We'll track and filter post-tally below.
    corp.push(candidate);
  }

  // Pattern 3: all-caps acronyms 2-6 chars (corporate defined terms).
  // v3.3 fix #3: short acronyms (3-4 chars) require frequency ≥ 2 to count.
  // Tracked separately so we can apply the floor after tallying.
  const acronyms: string[] = [];
  for (const m of corpus.matchAll(/\b([A-Z]{2,6})\b/g)) {
    acronyms.push(m[1]);
  }

  // Tally + rank
  const counts = new Map<string, number>();
  const allCandidates = [...quoted, ...corp, ...acronyms];
  for (const c of allCandidates) {
    const key = c.trim();
    if (key.length < 2) continue;
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }

  // v3.3 STOPLIST: structural / document-language / common non-party
  // acronyms PLUS all 50 US state abbreviations + Canadian provinces +
  // common international codes + payment terms. The CLIENT's company
  // is added dynamically so we never recommend it as a party.
  const STOPLIST = new Set<string>([
    // Legal / structural
    'JV', 'NDA', 'NDAS', 'MSA', 'MSAS', 'IP', 'SPV', 'D&O', 'ESG', 'KPI',
    'EBITDA', 'CAPEX', 'OPEX', 'ROFR', 'ROFO',
    // Currency / accounting / regulatory
    'USD', 'GBP', 'EUR', 'AUD', 'JPY', 'CNY', 'CAD', 'CHF', 'SEK', 'NOK',
    'SOFR', 'LIBOR', 'GAAP', 'IFRS', 'CPI', 'VAT', 'TAX', 'IRS', 'SEC',
    'FDA', 'EPA', 'OSHA', 'FTC', 'DOJ', 'GDPR', 'HIPAA', 'CCPA',
    // Countries / blocs
    'EU', 'UK', 'US', 'USA', 'PRC', 'EEA', 'ASEAN',
    // US state abbreviations
    'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
    'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
    'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
    'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
    'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
    'DC', 'PR', 'VI', 'GU', 'AS', 'MP',
    // Australian + Canadian + common abbreviations
    'NSW', 'VIC', 'QLD', 'TAS', 'ACT', 'NT', 'SA', 'WA',
    'ON', 'QC', 'BC', 'AB', 'MB', 'SK', 'NS', 'NB', 'NL', 'PE',
    // Address / form / payment terms
    'STE', 'APT', 'BLDG', 'PO', 'POB', 'NET', 'NETD', 'COD', 'MOM',
    'FOB', 'EXW', 'CIF', 'DDP', 'EOM', 'EOD', 'YOY', 'QOQ', 'MOM',
    'PILON', 'COBRA', 'ERISA',
    // Office titles (not parties)
    'CEO', 'CFO', 'COO', 'CTO', 'CIO', 'CMO', 'CHRO', 'GC', 'SVP', 'EVP',
    // Document structure
    'ARTICLE', 'CLAUSE', 'SECTION', 'EXHIBIT', 'SCHEDULE', 'ANNEX', 'APPENDIX',
    'AGREEMENT', 'CONTRACT', 'DEED', 'AMENDMENT', 'ADDENDUM', 'PREAMBLE',
    'RECITAL', 'RECITALS', 'WHEREAS', 'NOW', 'THEREFORE',
    // Common sentence-start capitalised words
    'AND', 'OR', 'THE', 'OF', 'TO', 'FOR', 'IN', 'ON', 'AT', 'BY', 'AS',
    'IF', 'IT', 'IS', 'BE', 'NO', 'WE', 'ALL', 'ANY', 'NOT', 'NEW',
    profile.company.toUpperCase().replace(/[^A-Z]/g, ''),
  ]);
  const clientCompanyLower = profile.company.toLowerCase();

  // Filename hints — strip extension + common separators, take alpha tokens
  const filenameHints = filename
    .replace(/\.[a-z]+$/i, '')
    .split(/[_\-\s.]+/)
    .filter(t => t.length >= 3 && /^[A-Za-z]/.test(t));

  // Build the final ranked list with v3.3 filters applied.
  const ranked = [...counts.entries()]
    .filter(([name, freq]) => {
      const upper = name.toUpperCase();
      if (STOPLIST.has(upper)) return false;
      if (name.toLowerCase().includes(clientCompanyLower) && clientCompanyLower.length > 3) return false;
      // Drop pure-number tokens and tiny ones
      if (/^\d+$/.test(name)) return false;
      // Drop "The" / "And" capitalised at sentence starts
      if (/^(The|And|Or|For|In|On|At|By|Of|To|As|It|Is|Be)$/.test(name)) return false;

      // v3.3 fix #3: short acronyms need frequency ≥ 2.
      // (Acronyms are uppercase-only, 3-4 char, no punctuation.)
      if (/^[A-Z]{3,4}$/.test(name) && freq < 2) return false;
      // 2-char all-caps also need ≥ 2 — most 2-char strings (US, JV, NY)
      // are STOPLIST'd, but anything that slipped through is suspect.
      if (/^[A-Z]{2}$/.test(name) && freq < 2) return false;

      // v3.3 fix #4: single-common-word + weak-corporate-suffix.
      // A candidate like "Capital Co", "Member Co", "Exchange Co" with a
      // SINGLE word before the suffix is almost always a regex artifact
      // from a longer phrase ("Capital Contributions"). Require freq ≥ 2.
      // Multi-word names ("BISYS Group", "First National Bank") survive.
      const weakSuffixSingleWord = /^[A-Z][A-Za-z]+\s+(?:Co|Group|Bank|AB)\.?$/;
      if (weakSuffixSingleWord.test(name) && freq < 2) return false;

      return true;
    })
    .sort((a, b) => b[1] - a[1] || a[0].length - b[0].length);

  // Take top 8 unique-on-prefix names
  const seen = new Set<string>();
  const result: string[] = [];
  for (const [name] of ranked) {
    const key = name.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    result.push(name);
    if (result.length >= 8) break;
  }

  // Light filename-hint boost: if a filename token looks party-like and isn't
  // already in the list, append it at the bottom (lowest priority).
  for (const hint of filenameHints) {
    const hintCap = hint[0].toUpperCase() + hint.slice(1).toLowerCase();
    if (!seen.has(hintCap.toLowerCase()) && !STOPLIST.has(hintCap.toUpperCase()) && result.length < 10) {
      result.push(hintCap);
      seen.add(hintCap.toLowerCase());
    }
  }

  return result;
}

/**
 * Post-synthesis validation: scan the summary for capitalised proper nouns
 * that are NOT in the required-party list, the client name, or known
 * jurisdiction names. Returns the list of "suspect" tokens. Caller can
 * decide whether to log, retry, or accept.
 *
 * This is a smoke detector, not a strict validator — the goal is to surface
 * obvious training-memorisation leaks (Bravatek, Veoneer-on-a-BorrowMoney-doc)
 * without false-positiving on legitimate corporate forms ("the Company",
 * "the Member") that appear in many contracts.
 */
function validateSummaryNames(
  summary: string,
  allowedNames: string[],
  profile: ClawProfile,
): string[] {
  // v3.4: NOTE — profile.company is INTENTIONALLY excluded from allowedLower.
  // The closed-vocabulary mechanism + the v3.4 prompt change keep the client
  // name out of the synthesis altogether. If it shows up in the summary
  // anyway, we want it surfaced as a suspect (and stripped by the post-process
  // step in `synthesise`). Previously (v3.3) we added profile.company here,
  // which masked the Veoneer "exposing Acme Holdings" leak from this check.
  const allowedLower = new Set(allowedNames.map(n => n.toLowerCase()));

  // Find candidate party-like phrases in the summary
  const candidates: string[] = [];
  for (const m of summary.matchAll(/\b([A-Z][a-zA-Z'\-]{2,}(?:\s+[A-Z][a-zA-Z'\-]{1,}){0,3})\b/g)) {
    const c = m[1].trim();
    // Skip sentence-starts and tiny phrases
    if (c.length < 4) continue;
    candidates.push(c);
  }

  // Known geography + legal-document words that are not parties.
  // Note: the client company is *not* on this list — if it appears in the
  // summary, it should be flagged as a suspect, not allowed.
  const GEO_AND_DOC_WORDS = new Set([
    'United', 'States', 'United States', 'United Kingdom', 'Australia',
    'New South Wales', 'New York', 'Delaware', 'California', 'Texas', 'Florida',
    'Japan', 'China', 'Sweden', 'Canada', 'Nevada', 'Arizona', 'Pennsylvania',
    'Article', 'Section', 'Clause', 'Exhibit', 'Schedule', 'Joint Venture',
    'Joint', 'Venture', 'Members', 'Member', 'Company', 'Companies',
    'Promoter', 'Operator', 'Executive', 'Assignor', 'Assignee', 'Director',
    'This', 'The', 'These', 'Those',
  ]);

  const suspects: string[] = [];
  for (const c of candidates) {
    const lower = c.toLowerCase();
    if (allowedLower.has(lower)) continue;
    // Exact match against the geo/doc-words list (case-sensitive enough)
    if (GEO_AND_DOC_WORDS.has(c)) continue;
    // Allow if every word in the candidate is a recognised geo/doc word
    const words = c.split(/\s+/);
    if (words.every(w => GEO_AND_DOC_WORDS.has(w))) continue;
    suspects.push(c);
  }
  // Dedupe while preserving order
  return Array.from(new Set(suspects));
}

interface Synthesis {
  summary: string;
  documentType: string;
  topRisks: Array<{ description: string; severity: 'low' | 'medium' | 'high' | 'critical'; citation: string }>;
  recommendations: string[];
  /** v3.2: deterministic party-name extraction surfaced for transparency. */
  extractedPartyNames?: string[];
  /** v3.2: capitalised proper nouns in the summary that are NOT in the
   *  closed vocabulary and NOT recognised geo/legal-doc words. Surfaces
   *  training-memorisation hallucinations the synthesis prompt didn't block. */
  suspectNames?: string[];
  /** v3.4: true if the synthesis emitted the client's company name in the
   *  summary, any risk description, or any recommendation. The synthesise()
   *  post-process strips these (replacing with "the client") before returning;
   *  this flag exists for the audit trail so the eval can score how often
   *  the post-process had to intervene. */
  clientNameLeaked?: boolean;
}

async function synthesise(
  cfg: OllamaConfig,
  findings: ClauseFinding[],
  filename: string,
  profile: ClawProfile,
): Promise<Synthesis> {
  // ── Two-pass synthesis for long documents ───────────────────────────────
  //
  // Phase 2 eval (v2) showed that gemma2:2b chokes on synthesis when fed
  // ~28 per-clause findings: it can't pick top risks and emits a single
  // mega-risk that lists 20+ clause numbers as a citation ("I can't choose,
  // so here's a meta-risk"). The summary also tends to over-cautious
  // placeholder language ("[other party]") instead of pulling names from
  // the per-clause findings.
  //
  // Fix: when finding-count > LONG_DOC_THRESHOLD, run a cheap deterministic
  // ranking pass first (severity + favours-against-client + concern count)
  // and feed only the top N to the synthesis. The model sees a focused
  // input that fits its effective context, and the final synthesis quality
  // matches what we get on short docs.
  const LONG_DOC_THRESHOLD = 15;
  const TOP_FINDINGS_FOR_SYNTHESIS = 10;
  const findingsForSynthesis = findings.length > LONG_DOC_THRESHOLD
    ? rankFindingsForSynthesis(findings, TOP_FINDINGS_FOR_SYNTHESIS)
    : findings;

  // Compact summary of (possibly ranked) per-clause findings
  const compactFindings = findingsForSynthesis.map(f => {
    const topConcerns = f.concerns
      .sort((a, b) => severityRank(b.severity) - severityRank(a.severity))
      .slice(0, 3);
    return [
      `cl ${f.clauseNumber} (${f.clauseTitle}) — favours ${f.favoursWhom}`,
      `  summary: ${f.clauseRiskSummary}`,
      ...topConcerns.map(c => `  [${c.severity}] ${c.text}`),
    ].join('\n');
  }).join('\n\n');

  const longDocNote = findings.length > LONG_DOC_THRESHOLD
    ? `(Ranked top ${findingsForSynthesis.length} of ${findings.length} clauses by severity. The other clauses had lower-severity findings and are still in the audit trail.)\n\n`
    : '';

  // v3.2 (Path A): programmatic party-name extraction. Builds a closed
  // vocabulary the synthesis prompt must constrain to. Prevents the small
  // model from substituting CUAD-memorised names ("Bravatek", "Veoneer +
  // Nissin") when the per-clause findings don't strongly anchor real
  // party names.
  const requiredNames = extractPartyNames(findingsForSynthesis, filename, profile);
  const requiredNamesBlock = requiredNames.length > 0
    ? [
        '',
        '=== PARTY NAMES EXTRACTED FROM THE PER-CLAUSE FINDINGS (CLOSED VOCABULARY) ===',
        'The following are the ONLY party names / defined terms that appear in the per-clause findings:',
        ...requiredNames.map(n => `  - ${n}`),
        'You MUST limit any party name you mention in the summary to this list. Names not on this list (companies you may recall from your training data, or any name not present in the per-clause findings above) MUST NOT appear in the summary. If you cannot identify a party from this list, write "(party not identified in findings)" instead of guessing.',
      ].join('\n')
    : '';

  // Profile is the client's STANCE, not document facts. The synthesis prompt
  // is told this explicitly; we also visually separate the blocks so the
  // model doesn't conflate them when reading top-down.
  //
  // v3.4: the client's COMPANY NAME is deliberately omitted from this block.
  // v3.3 eval (EVAL_REPORT_V33.md) surfaced a new leak vector: even though
  // the closed-vocabulary mechanism filtered the client name out of the
  // candidate parties list, the synthesis was still naming the client in
  // the risk-framing narrative ("potentially exposing Acme Holdings to
  // significant financial risk..."). gemma2:2b can't reliably distinguish
  // "stance" from "subject" — if the company name is in the prompt as a
  // tangible string, it leaks into prose. The structural fix: don't show
  // the name to the synthesis. Industry + jurisdiction + concerns are
  // enough framing context; the actual company name is never needed for
  // the executive summary.
  const userMessage = [
    '=== DOCUMENT (the source of facts) ===',
    `Filename: ${filename}`,
    '',
    '=== CLIENT STANCE (use ONLY for risk framing — NOT as document facts) ===',
    `Client's industry: ${profile.industry}`,
    `Client's home jurisdiction: ${profile.jurisdiction}`,
    `Client's stated concerns: ${profile.concerns.join(', ')}`,
    'NOTE: the client reviewing this document may or may not be a party to it. If you need to refer to the reviewer in the summary or in risk descriptions, use "the client" — NEVER a specific company name. Specific company names come ONLY from the per-clause findings.',
    '',
    '=== PER-CLAUSE FINDINGS FROM THE TEAM (the source of truth for parties, jurisdiction, subject matter) ===',
    longDocNote + compactFindings,
    requiredNamesBlock,
    '',
    'Now produce the executive synthesis JSON. Rules:',
    '  - Parties, jurisdiction, and document type come from the PER-CLAUSE FINDINGS only.',
    '  - Names of parties: copy them VERBATIM from the per-clause findings, whatever they are. Do not substitute company names from your prior knowledge. Do not invent.',
    requiredNames.length > 0
      ? '  - HARD CONSTRAINT: party names in your output must be drawn from the CLOSED VOCABULARY block above. Any name not on that list is a violation.'
      : '  - If no party names were identified in the per-clause findings, write "(parties not identified by per-clause analysis)" instead of guessing.',
    '  - Empty brackets like "[other party]" or "[venture purpose]" are FORBIDDEN. If a fact is not in the per-clause findings, write "(not stated in document)" instead.',
    '  - Pick AT LEAST 3 distinct topRisks. A single risk that lists many clause numbers as one citation is NOT acceptable — split it into separate risks per clause group.',
    '  - Risk descriptions: describe what is in the DOCUMENT, not what the client is worried about. Do NOT recite the CLIENT\'S stated concerns list verbatim. If the document is a vendor outsourcing agreement, do not invent "capital call" or "joint and several JV liability" risks because those words appear in the client\'s concerns — those risks only apply when the DOCUMENT actually contains those mechanics.',
  ].join('\n');

  const raw = await ollamaJsonChat(cfg, SYNTHESIS_PROMPT, userMessage, 1500) as Record<string, unknown>;

  const rawSummary = typeof raw.summary === 'string' ? raw.summary : `Local analysis of ${filename}`;
  const rawRisks = Array.isArray(raw.topRisks) ? raw.topRisks.map(r => {
    const rr = r as Record<string, unknown>;
    return {
      description: typeof rr.description === 'string' ? rr.description : '',
      severity: validateRiskSeverity(typeof rr.severity === 'string' ? rr.severity : undefined, 'medium'),
      citation: typeof rr.citation === 'string' ? rr.citation : '',
    };
  }).filter(r => r.description.length > 0) : [];
  const rawRecommendations = Array.isArray(raw.recommendations)
    ? raw.recommendations.filter((s): s is string => typeof s === 'string' && s.length > 5)
    : [];

  // v3.4 defensive post-process: even with the client name removed from the
  // synthesis user-message (v3.4 fix #1), gemma2:2b can still produce the
  // client name in narrative text if it's recurring in the per-clause
  // findings or if the model latches onto a pattern from training. We do a
  // word-boundary regex replace of `profile.company` with "the client" in
  // the summary, every risk description, and every recommendation, and
  // surface a `clientNameLeaked` boolean on the result for the audit trail.
  const { sanitised: summary, leaked: summaryLeak } = stripClientName(rawSummary, profile.company);
  const sanitisedRisks = rawRisks.map(r => {
    const { sanitised, leaked } = stripClientName(r.description, profile.company);
    return { ...r, description: sanitised, _leaked: leaked };
  });
  const risksLeaked = sanitisedRisks.some(r => r._leaked);
  const sanitisedRecommendations = rawRecommendations.map(rec => stripClientName(rec, profile.company).sanitised);
  const clientNameLeaked = summaryLeak || risksLeaked;

  // v3.2: post-synthesis party-name validation. Surfaces suspect names
  // (capitalised proper nouns in the summary that are NOT in the closed
  // vocabulary and NOT recognised geo/legal-doc words). v3.4: now also
  // catches profile.company appearances (which v3.3 accidentally masked).
  const suspectNames = validateSummaryNames(summary, requiredNames, profile);

  return {
    summary,
    documentType: typeof raw.documentType === 'string' ? raw.documentType : 'Document',
    topRisks: sanitisedRisks.map(r => ({ description: r.description, severity: r.severity, citation: r.citation })),
    recommendations: sanitisedRecommendations,
    // v3.2 transparency signals (consumed by eval; UI ignores)
    extractedPartyNames: requiredNames,
    suspectNames,
    // v3.4 transparency: did we strip a client-name leak post-synthesis?
    clientNameLeaked,
  };
}

/**
 * v3.4: replace word-boundary occurrences of the client's company name in a
 * given string with "the client". Returns the sanitised string + a flag
 * indicating whether any replacement was made. Case-insensitive match;
 * preserves case via "the client" / "The client" depending on position.
 *
 * Also handles common possessive form (e.g., "Acme Holdings's" → "the
 * client's") to keep the output readable.
 */
function stripClientName(text: string, companyName: string): { sanitised: string; leaked: boolean } {
  if (!text || !companyName || companyName.length < 3) return { sanitised: text, leaked: false };
  // Escape special regex chars in the company name
  const escaped = companyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  // Match the full name AND its possessive forms (with apostrophe-s, with trailing 's)
  const re = new RegExp(`\\b${escaped}(?:['’]s|s')?\\b`, 'gi');
  let leaked = false;
  const sanitised = text.replace(re, (match, _offset, fullText) => {
    leaked = true;
    // Preserve the leading-capitalisation context: if the match is at the
    // start of a sentence, use "The client"; otherwise "the client".
    const startsSentence = (typeof _offset === 'number')
      ? (_offset === 0 || /[.!?]\s*$/.test((fullText as string).slice(0, _offset)))
      : false;
    const possessive = /['’]s$|s'$/.test(match);
    if (startsSentence) return possessive ? "The client's" : 'The client';
    return possessive ? "the client's" : 'the client';
  });
  return { sanitised, leaked };
}

// ── Main Entry Point ─────────────────────────────────────────────────────

/**
 * Analyse a document entirely on-device using a local model.
 *
 * v2 pipeline:
 *   1. Chunk the document by clause boundaries (clauseN. Title).
 *   2. For each clause, run a focused per-clause analysis prompt.
 *   3. Run a synthesis prompt that merges per-clause findings into the
 *      executive summary, top-N risks, and recommendations.
 *
 * @throws If the local model is unreachable or returns invalid output
 */
export interface AnalyzeLocallyOptions {
  /** Lighthouse Phase 2: precedent board to query for per-clause context.
   *  When omitted, Reader runs without precedent injection (legacy behavior). */
  precedentBoard?: PrecedentBoard;
  /** Lighthouse Phase 1+: Watchman triage result. Used to scope precedent
   *  search by documentType + jurisdiction. */
  watchman?: WatchmanResult;
}

export async function analyzeLocally(
  documentText: string,
  filename: string,
  profile: ClawProfile,
  log?: (msg: string) => void,
  opts?: AnalyzeLocallyOptions,
): Promise<LocalAnalysisResult> {
  const cfg = resolveOllamaConfig();
  const chunks = chunkByClauseBoundaries(documentText);

  log?.(`  → ${chunks.length} clause${chunks.length === 1 ? '' : 's'} detected`);

  // Optional precedent context — queried once by document type + jurisdiction.
  // Per-clause precedent matching could narrow this further by clause topic
  // (a Phase 2.1 enhancement); for now we use a single document-scoped query
  // so the Reader sees consistent firm context across clauses.
  //
  // Multi-jurisdiction handling: the Watchman often returns a comma-joined
  // string (e.g., "nsw,japan,china" for a tri-party JV). The board's filter
  // does `entry.jurisdiction.includes(query.jurisdiction)`, which never
  // matches a single-jurisdiction stored value against a multi-jurisdiction
  // query. We split the query on commas and run a search per token, then
  // dedupe by precedent ID. Also try an UNFILTERED-by-jurisdiction search
  // as a final fallback — the document type alone is a strong signal.
  let documentPrecedents: PrecedentMatch[] = [];
  if (opts?.precedentBoard && opts?.watchman) {
    try {
      const rawJur = opts.watchman.jurisdiction || profile.jurisdiction || '';
      const tokens = rawJur.split(/[,;\/]+/).map(s => s.trim()).filter(s => s.length >= 2);
      const seen = new Set<string>();
      const collected: PrecedentMatch[] = [];

      // Per-jurisdiction-token search
      for (const j of tokens) {
        const results = opts.precedentBoard.search({
          documentType: opts.watchman.documentType,
          jurisdiction: j,
          limit: 5,
        });
        for (const r of results) {
          if (!seen.has(r.entry.id)) { seen.add(r.entry.id); collected.push(r); }
        }
      }

      // Fallback: documentType-only search if jurisdiction filters returned nothing
      if (collected.length === 0) {
        const results = opts.precedentBoard.search({
          documentType: opts.watchman.documentType,
          limit: 5,
        });
        for (const r of results) {
          if (!seen.has(r.entry.id)) { seen.add(r.entry.id); collected.push(r); }
        }
      }

      // Take top 5 by relevance (the search already sorted within each token)
      documentPrecedents = collected.sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, 5);

      if (documentPrecedents.length > 0) {
        log?.(`  → ${documentPrecedents.length} precedent(s) loaded for context`);
      }
    } catch (err) {
      log?.(`    ⚠ precedent lookup failed (${err instanceof Error ? err.message : err}) — proceeding without`);
    }
  }

  // Phase 6: pick the Reader template based on Watchman's documentType.
  // Defaults to 'other' (generic) when no Watchman result is available
  // (legacy callers, or processing before lighthouse rolled out).
  const docType: WatchmanDocumentType = opts?.watchman?.documentType ?? 'other';
  if (opts?.watchman) {
    log?.(`  → reader template: ${docType} (Watchman conf ${opts.watchman.confidence.toFixed(2)})`);
  }

  const findings: ClauseFinding[] = [];
  for (const [i, chunk] of chunks.entries()) {
    log?.(`  → analysing cl ${chunk.number} (${chunk.title.slice(0, 50)}) — ${i + 1}/${chunks.length}`);
    try {
      // Inject the top-3 most-relevant precedents. We pass the document-scoped
      // list to every clause; the model decides which (if any) apply.
      const finding = await analyseClause(cfg, chunk, profile, documentPrecedents.slice(0, 3), docType);
      findings.push(finding);
    } catch (err) {
      log?.(`    ✗ cl ${chunk.number} failed: ${err instanceof Error ? err.message : err}`);
      // Carry on — partial analysis is still useful
    }
  }

  // Phase 2: deterministic grounding pass — strips concerns whose claims
  // cannot be anchored to clause text via clause-numbers, dollar amounts,
  // percentages, periods, or named references.
  const unanchoredStripped = groundingPass(findings);
  if (unanchoredStripped > 0) {
    log?.(`  → grounding pass: ${unanchoredStripped} unanchored finding(s) flagged`);
  }
  // Drop unanchored concerns from the per-clause findings so synthesis sees
  // only grounded material. The audit trail can be reconstructed from the
  // unanchoredStripped count if needed.
  for (const f of findings) {
    f.concerns = f.concerns.filter(c => !c.unanchored);
  }

  log?.(`  → synthesising executive summary (${findings.length} clause findings)`);
  let syn: Synthesis;
  try {
    syn = await synthesise(cfg, findings, filename, profile);
  } catch (err) {
    log?.(`  ✗ synthesis failed (${err instanceof Error ? err.message : err}) — falling back to per-clause aggregate`);
    syn = aggregateFallback(findings, filename);
  }

  // Convert per-clause findings into the legacy ClauseAnalysis shape
  const clauses: ClauseAnalysis[] = findings.map(f => {
    const top = f.concerns.sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
    return {
      title: `${f.clauseNumber}. ${f.clauseTitle}`,
      text: f.operativeText,
      concern: top
        ? top.text
        : (f.clauseRiskSummary || `Clause ${f.clauseNumber} reviewed — no specific concern raised.`),
      severity: top ? top.severity : 'info',
    };
  });

  // Dedupe precedent IDs surfaced into any clause's findings.
  const precedentsConsulted: string[] = Array.from(new Set(
    findings.flatMap(f => f.concerns.map(c => c.precedentMatch).filter((id): id is string => !!id))
      .concat(documentPrecedents.map(m => m.entry.id))
  ));

  // Phase 5 (lighthouse): reinforce precedents that were applied. Done here
  // so the board's effectiveness scores track local-Reader usage too, not
  // just frontier dispatch. Soft-fail — a precedent reinforce error must
  // never poison the deliverable.
  if (opts?.precedentBoard) {
    for (const precId of precedentsConsulted) {
      try {
        opts.precedentBoard.reinforce(precId, `local-${cfg.modelName}-${Date.now()}`, 0.05);
      } catch { /* non-fatal */ }
    }
  }

  return {
    summary: syn.summary,
    documentType: syn.documentType,
    clauses,
    risks: syn.topRisks,
    recommendations: syn.recommendations,
    confidenceNote:
      'This document was analysed entirely on-device using a local model. No content was transmitted externally. ' +
      'For complex legal matters, verify findings with qualified counsel.',
    model: cfg.modelName,
    precedentsConsulted: precedentsConsulted.length > 0 ? precedentsConsulted : undefined,
    unanchoredStripped: unanchoredStripped > 0 ? unanchoredStripped : undefined,
    clientNameLeaked: syn.clientNameLeaked === true ? true : undefined,
  };
}

function aggregateFallback(findings: ClauseFinding[], filename: string): Synthesis {
  // If synthesis crashed (e.g. model timed out on the final pass), produce
  // a usable result from the per-clause output alone.
  const allConcerns = findings.flatMap(f => f.concerns.map(c => ({ f, c })));
  const sorted = allConcerns.sort((a, b) => severityRank(b.c.severity) - severityRank(a.c.severity));
  const topRisks = sorted.slice(0, 5).map(({ f, c }) => ({
    description: c.text,
    severity: c.severity === 'critical' ? 'critical' as const
            : c.severity === 'major'    ? 'high' as const
            : c.severity === 'minor'    ? 'medium' as const
            : 'low' as const,
    citation: `cl ${f.clauseNumber}`,
  }));
  return {
    summary: `Local clause-by-clause analysis of ${filename} (${findings.length} clauses analysed).`,
    documentType: 'Contract',
    topRisks,
    recommendations: [],
  };
}

// ── Helpers ──────────────────────────────────────────────────────────────

function severityRank(s: string): number {
  return { critical: 4, major: 3, high: 3, minor: 2, medium: 2, info: 1, low: 1 }[s] ?? 0;
}

function validateSeverity(
  s: string | undefined,
  fallback: 'info' | 'minor' | 'major' | 'critical',
): 'info' | 'minor' | 'major' | 'critical' {
  const valid = ['info', 'minor', 'major', 'critical'];
  return valid.includes(s ?? '') ? (s as 'info' | 'minor' | 'major' | 'critical') : fallback;
}

function validateRiskSeverity(
  s: string | undefined,
  fallback: 'low' | 'medium' | 'high' | 'critical',
): 'low' | 'medium' | 'high' | 'critical' {
  const valid = ['low', 'medium', 'high', 'critical'];
  return valid.includes(s ?? '') ? (s as 'low' | 'medium' | 'high' | 'critical') : fallback;
}

function validateFavours(s: string | undefined): 'operator' | 'non-operator' | 'neutral' {
  if (s === 'operator' || s === 'non-operator' || s === 'neutral') return s;
  return 'neutral';
}

/**
 * Extract findings summary from local analysis result.
 * Maps local severity to the standard findings format.
 */
export function extractLocalFindings(
  result: LocalAnalysisResult,
): { critical: number; major: number; minor: number } {
  let critical = 0;
  let major = 0;
  let minor = 0;

  for (const clause of result.clauses) {
    if (clause.severity === 'critical') critical++;
    else if (clause.severity === 'major') major++;
    else minor++;
  }

  for (const risk of result.risks) {
    if (risk.severity === 'critical') critical++;
    else if (risk.severity === 'high') major++;
    else minor++;
  }

  return { critical, major, minor };
}

/**
 * Convert a LocalAnalysisResult into Finding[] for precedent indexing.
 * The precedent board only indexes RED/YELLOW findings with confidence >= 0.7
 * and non-empty evidence, so we map local severities accordingly and assign
 * a default confidence of 0.8 (local model produced a structured output).
 */
export function localResultToFindings(result: LocalAnalysisResult): import('../types/debate.js').Finding[] {
  const now = new Date().toISOString();
  const findings: import('../types/debate.js').Finding[] = [];

  for (let i = 0; i < result.clauses.length; i++) {
    const c = result.clauses[i];
    const severity: 'RED' | 'YELLOW' | 'GREEN' =
      c.severity === 'critical' ? 'RED'
      : c.severity === 'major' ? 'YELLOW'
      : 'GREEN';
    if (severity === 'GREEN' || !c.text) continue;
    findings.push({
      id: `local-clause-${i}`,
      agentRole: 'reviewer' as import('../types/index.js').AgentRole,
      findingType: 'contract-risk',
      content: c.concern || c.title,
      severity,
      evidence: [c.text],
      confidence: 0.8,
      timestamp: now,
      resolved: false,
    });
  }

  for (let i = 0; i < result.risks.length; i++) {
    const r = result.risks[i];
    const severity: 'RED' | 'YELLOW' | 'GREEN' =
      r.severity === 'critical' || r.severity === 'high' ? 'RED'
      : r.severity === 'medium' ? 'YELLOW'
      : 'GREEN';
    if (severity === 'GREEN' || !r.citation) continue;
    findings.push({
      id: `local-risk-${i}`,
      agentRole: 'reviewer' as import('../types/index.js').AgentRole,
      findingType: 'contract-risk',
      content: r.description,
      severity,
      evidence: [r.citation],
      confidence: 0.8,
      timestamp: now,
      resolved: false,
    });
  }

  return findings;
}
