// lib/tools/adapters/mines/report-generate.js
//
// mines_report_generate — assemble a cited, provenance-complete report artifact.
//
// P2 ships two report kinds:
//   - data_quality_report  : completeness per document_category + spatial count
//   - interpretation_memo  : cited narrative for the project
//
// Provenance invariant (engineering-design §5): every NUMERIC claim traces to a
// mines_assay_samples / mines_drill_holes aggregate or a mines_extracted_facts
// row; every NARRATIVE claim cites a mines_document_chunks id + page. This
// adapter only emits numbers it can back with a real row/aggregate — it never
// invents figures. Other report kinds (valuation_report, due_diligence_questions,
// project_package, portfolio_screen) arrive in later phases.
//
// Risk: high (base). External email delivery is out of P2 scope; send_email is
// rejected here and will delegate to the irreversible_external email_send tool
// in a later phase.

import { getDb } from '../../../db.js';
import { createArtifact, deleteArtifact } from '../../../runtime.js';
import { getProjectDataQuality } from '../../../mines/data-quality.js';
import { retrieveChunks } from '../../../mines/rag.js';
import { appendComplianceAudit } from '../../../compliance-audit.js';
import { buildInvestorPresentation } from './investor-presentation.js';
import { resolveTargetSpace, moveArtifactToSpace } from '../../../dashboard-spaces.js';

const SUPPORTED_KINDS = new Set([
  'data_quality_report', 'interpretation_memo', 'due_diligence_memo', 'investor_presentation', 'project_package',
  'pfs_readiness_report',
]);

// App origin for the project_package wrapper iframe. srcDoc has no base URL, so
// the embedded results route needs an absolute URL. Local-first default; the app
// listens on 8787 (see CLAUDE.md). Override with MADARX_APP_ORIGIN when deployed.
const APP_ORIGIN = process.env.MADARX_APP_ORIGIN || 'http://127.0.0.1:8787';

// Whole-page package: a thin wrapper that renders the live diligence results
// route full-bleed. In a dashboard artifact window (sandbox allow-same-origin)
// this surfaces the entire page as one movable/resizable artifact.
function buildProjectPackageHtml(project) {
  const src = `${APP_ORIGIN}/mines/${project.id}/results`;
  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${project.name} — Diligence package</title>
<style>html,body{margin:0;height:100%;background:#fff}iframe{display:block;width:100%;height:100%;border:0}</style>
</head><body><iframe src="${src}" title="${project.name} diligence package" loading="lazy"></iframe></body></html>`;
}

function round(n, d = 3) {
  if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
  const f = 10 ** d;
  return Math.round(Number(n) * f) / f;
}

function fmtUsd(n) {
  if (n == null || !Number.isFinite(Number(n))) return 'n/a';
  const v = Number(n);
  if (Math.abs(v) >= 1e9) return `$${(v / 1e9).toFixed(2)}B`;
  if (Math.abs(v) >= 1e6) return `$${(v / 1e6).toFixed(1)}M`;
  if (Math.abs(v) >= 1e3) return `$${(v / 1e3).toFixed(0)}k`;
  return `$${v.toFixed(0)}`;
}

function fmtNum(n, d = 0) {
  if (n == null || !Number.isFinite(Number(n))) return 'n/a';
  return new Intl.NumberFormat('en-US', { maximumFractionDigits: d }).format(Number(n));
}

// ── LLM narrative provenance guard ──────────────────────────────────────────
// Every report's prose is authored by the agent/LLM; every NUMBER is rendered
// deterministically from cited SQL rows. To keep the load-bearing guarantee
// that an LLM cannot state a grade/tonnage/value the source never contained,
// any figure that leaks into prose is redacted here (defense-in-depth — the
// authoring system prompt already bans numbers in prose). Bare 4-digit years
// (19xx/20xx) survive; everything digit+unit / currency / percent is scrubbed.
const FIGURE_RE = new RegExp([
  '\\$\\s?\\d[\\d,]*(?:\\.\\d+)?(?:\\s?(?:B|M|bn|k))?',          // currency amount
  '\\d[\\d,]*(?:\\.\\d+)?\\s?(?:%|\u2030)',                          // percent / per-mille
  '\\b\\d[\\d,]*(?:\\.\\d+)?\\s?(?:ppm|ppb|g\\/t|gpt|oz|lbs?|kg|kt|mt|moz|mlb|tonnes?|tons?|km|cm|mm|m|ha|usd|t)(?![a-z])', // number + unit
  '\\b\\d{1,3}(?:,\\d{3})+\\b',                                         // comma-grouped thousands
  '\\b\\d+\\.\\d+\\b',                                                // any decimal
].join('|'), 'gi');
function stripProseNumbers(text) {
  if (text == null) return '';
  // (years are never matched by FIGURE_RE since they lack a trailing unit)
  return String(text).replace(FIGURE_RE, '[see figures]');
}

// Pull a number-free prose slot from an LLM-authored narrative object. Returns
// '' when the slot is absent — callers OMIT the section rather than substitute
// deterministic boilerplate (agent is the sole author of prose).
function proseSlot(narrative, key) {
  const v = narrative && typeof narrative === 'object' ? narrative[key] : null;
  if (Array.isArray(v)) return v.map((p) => stripProseNumbers(p).trim()).filter(Boolean).join('\n\n');
  return v ? stripProseNumbers(v).trim() : '';
}

// Number-scrub every LLM-authored slot of a narrative object up-front. The
// markdown builders read each slot through proseSlot() (which strips numbers),
// but the investor-presentation HTML builder reads slots directly via esc().
// Scrubbing here keeps the provenance invariant — no figure leaks into prose —
// on every report kind and on either engine (the CLI tier's free-text JSON is
// less reliable than the API forced-tool, so this redaction is load-bearing).
function scrubNarrative(narrative) {
  if (!narrative || typeof narrative !== 'object') return {};
  const out = {};
  for (const [k, v] of Object.entries(narrative)) {
    if (typeof v === 'string') out[k] = stripProseNumbers(v);
    else if (Array.isArray(v)) out[k] = v.map((p) => (typeof p === 'string' ? stripProseNumbers(p) : p));
    else out[k] = v;
  }
  return out;
}

function resolveOsProjectId(db, minesProjectId) {
  const row = db.prepare('SELECT os_project_id FROM mines_projects WHERE id = ?').get(minesProjectId);
  return row?.os_project_id || null;
}

// ── data_quality_report ───────────────────────────────────────────────────────

function buildDataQualityMarkdown(project, dq, narrative = {}) {
  const lines = [];
  // No leading H1 — the artifact window/card already renders the title, so an
  // in-body title heading would duplicate it.
  lines.push(`Generated ${new Date().toISOString().slice(0, 10)} · Project \`${project.id}\``);
  lines.push('');
  // LLM-authored intro (number-free; figures rendered from SQL below).
  const lead = proseSlot(narrative, 'lead');
  if (lead) { lines.push(lead); lines.push(''); }
  lines.push(`**Overall completeness score:** ${dq.completeness_score} (fraction of document categories with ≥1 fully extracted document)`);
  lines.push('');
  lines.push(`**Totals:** ${dq.totals.documents} documents · ${dq.totals.chunks} text chunks · ${dq.spatial_assets_count} spatial assets`);
  lines.push('');
  lines.push('## Completeness by category');
  lines.push('');
  lines.push('| Category | Documents | Chunks | Status breakdown |');
  lines.push('|----------|-----------|--------|------------------|');
  if (dq.categories.length === 0) {
    lines.push('| _(no documents imported)_ | 0 | 0 | — |');
  } else {
    for (const c of dq.categories) {
      const breakdown = Object.entries(c.status_breakdown)
        .map(([k, v]) => `${k}: ${v}`).join(', ') || '—';
      lines.push(`| \`${c.document_category}\` | ${c.document_count} | ${c.chunk_count} | ${breakdown} |`);
    }
  }
  lines.push('');
  lines.push('## Import progress');
  lines.push('');
  const p = dq.progress;
  lines.push(`- Queued: ${p.queued}`);
  lines.push(`- Running: ${p.running}`);
  lines.push(`- Complete: ${p.complete}`);
  lines.push(`- Failed: ${p.failed}`);
  lines.push(`- Skipped / catalog-only: ${p.skipped}`);
  lines.push('');
  const gaps = proseSlot(narrative, 'gaps');
  if (gaps) { lines.push('## Assessment'); lines.push(''); lines.push(gaps); lines.push(''); }
  lines.push('---');
  lines.push('_Counts are computed directly from `mines_source_documents`, `mines_document_chunks`, and `mines_spatial_assets`._');
  return lines.join('\n');
}

// ── interpretation_memo ───────────────────────────────────────────────────────

async function buildInterpretationMemo(db, project, narrative = {}) {
  const projectId = project.id;

  // SQL-backed numeric claims (each cites its source rows/aggregate).
  const assay = db.prepare(`
    SELECT COUNT(*) AS n,
           AVG(au_ppm) AS avg_au, MAX(au_ppm) AS max_au,
           AVG(ag_ppm) AS avg_ag,
           AVG(cu_pct) AS avg_cu, MAX(cu_pct) AS max_cu,
           SUM(CASE WHEN au_ppm IS NOT NULL THEN 1 ELSE 0 END) AS au_n,
           SUM(CASE WHEN cu_pct IS NOT NULL THEN 1 ELSE 0 END) AS cu_n
    FROM mines_assay_samples WHERE project_id = ?
  `).get(projectId);
  const drill = db.prepare(`
    SELECT COUNT(*) AS n, AVG(total_depth_m) AS avg_depth, MAX(total_depth_m) AS max_depth,
           SUM(CASE WHEN total_depth_m IS NOT NULL THEN 1 ELSE 0 END) AS depth_n
    FROM mines_drill_holes WHERE project_id = ?
  `).get(projectId);
  const facts = db.prepare(
    'SELECT id, fact_type, value_text, source_document_id, page_or_location FROM mines_extracted_facts WHERE project_id = ? ORDER BY fact_type'
  ).all(projectId);

  const numericClaims = [];
  if ((assay?.n || 0) > 0) {
    numericClaims.push(`- Assay dataset holds **${assay.n} samples**. [AssaySample aggregate, project ${projectId}]`);
    if ((assay.au_n || 0) > 0) {
      numericClaims.push(`- Mean gold grade is **${round(assay.avg_au)} ppm Au** across ${assay.au_n} graded samples (max ${round(assay.max_au)} ppm). [AssaySample.au_ppm aggregate]`);
    }
    if ((assay.cu_n || 0) > 0) {
      numericClaims.push(`- Mean copper grade is **${round(assay.avg_cu)} % Cu** across ${assay.cu_n} graded samples (max ${round(assay.max_cu)} %). [AssaySample.cu_pct aggregate]`);
    }
  }
  if ((drill?.n || 0) > 0) {
    numericClaims.push(`- Drill campaign comprises **${drill.n} holes**${(drill.depth_n || 0) > 0 ? `, averaging ${round(drill.avg_depth, 1)} m depth (max ${round(drill.max_depth, 1)} m)` : ''}. [DrillHole aggregate]`);
  }
  for (const f of facts) {
    const cite = f.source_document_id
      ? `[ExtractedFact:${f.id}, SourceDocument:${f.source_document_id}${f.page_or_location ? `, ${f.page_or_location}` : ''}]`
      : `[ExtractedFact:${f.id}]`;
    numericClaims.push(`- ${f.fact_type.replace(/_/g, ' ')}: **${f.value_text}** ${cite}`);
  }

  // RAG-backed narrative (each chunk cited by DocumentChunk.id + page).
  // Retrieval is best-effort: a degraded/throwing RAG index must not drop the
  // whole report. Fall back to an empty narrative section.
  const query = [project.name, project.commodity, 'geología mineralización recursos geology mineralization resource'].filter(Boolean).join(' ');
  let mode = 'text';
  let chunks = [];
  try {
    ({ mode, chunks } = await retrieveChunks(projectId, query, 6));
  } catch { /* RAG optional — keep numeric claims, skip narrative passages */ }

  const lines = [];
  // No leading H1 — the artifact window/card already renders the title.
  lines.push(`Generated ${new Date().toISOString().slice(0, 10)} · Project \`${projectId}\` · Stage: ${project.asset_stage}`);
  lines.push('');
  const lead = proseSlot(narrative, 'lead');
  if (lead) { lines.push(lead); lines.push(''); }
  lines.push('## Quantitative summary');
  lines.push('');
  lines.push('_Every figure below traces to a SQL row or aggregate (provenance invariant)._');
  lines.push('');
  if (numericClaims.length) lines.push(...numericClaims);
  else lines.push('- No assay, drill-hole, or extracted-fact data is available yet for this project. No numeric claims are made.');
  lines.push('');
  const interp = proseSlot(narrative, 'interpretation');
  if (interp) { lines.push('## Interpretation'); lines.push(''); lines.push(interp); lines.push(''); }
  lines.push('## Narrative context');
  lines.push('');
  if (chunks.length) {
    lines.push(`_Retrieved via ${mode === 'embedding' ? 'vector similarity' : 'text search'} over document chunks. Each passage cites its source._`);
    lines.push('');
    for (const c of chunks) {
      const snippet = String(c.text || '').replace(/\s+/g, ' ').trim().slice(0, 400);
      lines.push(`> ${snippet}${snippet.length >= 400 ? '…' : ''}`);
      lines.push('');
      lines.push(`— [DocumentChunk:${c.id}, SourceDocument:${c.source_document_id}, page ${c.page ?? 'n/a'}, category ${c.document_category}]`);
      lines.push('');
    }
  } else {
    lines.push('No document chunks are available yet. Run `mines_data_import` + document extraction to populate the RAG index.');
    lines.push('');
  }
  lines.push('---');
  lines.push('_Numeric claims are SQL-traced; narrative passages cite `DocumentChunk.id` + page. Generated by `mines_report_generate`._');

  return {
    markdown: lines.join('\n'),
    numeric_claims_count: numericClaims.length,
    chunk_citations_count: chunks.length,
    retrieval_mode: mode,
  };
}

// ── due_diligence_memo ─────────────────────────────────────────────────────────
//
// Comprehensive, client-grade investment diligence package. Generic and
// provenance-driven: it composes whatever structured facts, comparables,
// targets, scenarios, and SQL aggregates exist for the project. Every numeric
// claim traces to a fact id, an aggregate, or a persisted scenario row. It does
// not invent figures — when a definitive valuation is unavailable it says so
// and references the operator scenario as a clearly-labelled estimate.

function factMap(db, projectId) {
  const rows = db.prepare(
    'SELECT id, fact_type, value_text, page_or_location FROM mines_extracted_facts WHERE project_id = ? ORDER BY fact_type'
  ).all(projectId);
  const byType = new Map();
  for (const r of rows) {
    if (!byType.has(r.fact_type)) byType.set(r.fact_type, []);
    byType.get(r.fact_type).push(r);
  }
  return byType;
}

function factLine(byType, type) {
  const f = (byType.get(type) || [])[0];
  if (!f) return null;
  return { text: f.value_text, cite: `[ExtractedFact:${f.id}${f.page_or_location ? `, ${f.page_or_location}` : ''}]` };
}

function buildDueDiligenceMemo(db, project, narrative = {}) {
  const projectId = project.id;
  const byType = factMap(db, projectId);
  const L = [];
  const today = new Date().toISOString().slice(0, 10);

  // SQL aggregates.
  const assay = db.prepare(`
    SELECT COUNT(*) n, AVG(cu_pct) avg_cu, MAX(cu_pct) max_cu, AVG(ag_ppm) avg_ag
    FROM mines_assay_samples WHERE project_id = ? AND sample_type = 'trench_channel'
  `).get(projectId);
  const drill = db.prepare('SELECT COUNT(*) n, SUM(total_depth_m) m FROM mines_drill_holes WHERE project_id = ?').get(projectId);
  const intercepts = db.prepare(`
    SELECT sample_id, width_m, cu_pct, ag_ppm, other_grades_json
    FROM mines_assay_samples WHERE project_id = ? AND sample_type = 'drill_core'
    ORDER BY (cu_pct * width_m) DESC LIMIT 8
  `).all(projectId);
  const docCount = db.prepare('SELECT COUNT(*) n FROM mines_source_documents WHERE project_id = ?').get(projectId).n;
  const chunkCount = db.prepare('SELECT COUNT(*) n FROM mines_document_chunks WHERE project_id = ?').get(projectId).n;

  // Latest prices.
  const priceRow = (c) => db.prepare(
    'SELECT price_usd, price_unit, source FROM mines_market_snapshots WHERE lower(commodity)=lower(?) ORDER BY fetched_at DESC LIMIT 1'
  ).get(c);
  const cu = priceRow('Cu');
  const ag = priceRow('Ag');

  // Operator scenario rows (clearly-labelled estimate, not a resource).
  const scen = db.prepare(
    "SELECT scenario, assumptions_json, result_json FROM mines_valuation_scenarios WHERE project_id = ? AND scenario LIKE 'operator_%' ORDER BY scenario"
  ).all(projectId);

  // ── Header ── (no leading H1 — the artifact window/card already renders the title)
  L.push(`**Prepared by:** MadarX OS · Mines  |  **Date:** ${today}  |  **Project:** \`${projectId}\``);
  L.push(`**Asset stage:** ${project.asset_stage}  |  **Commodity:** ${project.commodity || 'Cu'}  |  **Jurisdiction:** ${project.state_province || 'Sonora'}, ${project.country}`);
  L.push('');
  L.push('> This memorandum is generated by autonomous agents from the project document archive and structured database. Every figure traces to a source document, a database aggregate, or a labelled estimate. Figures attributed to an operator scenario are conceptual and are **not** mineral resources.');
  L.push('');

  // ── 1. Executive summary ──
  L.push('## 1. Executive summary');
  L.push('');
  const dep = factLine(byType, 'deposit_type');
  const stage = factLine(byType, 'asset_stage');
  // LLM-authored prose (number-free). Cited facts/figures rendered separately.
  const execSummary = proseSlot(narrative, 'exec_summary');
  if (execSummary) { L.push(execSummary); L.push(''); }
  if (dep) { L.push(`- **Deposit type:** ${dep.text} ${dep.cite}`); }
  const thesis = proseSlot(narrative, 'thesis');
  if (thesis) { L.push(`**Investment thesis.** ${thesis}`); L.push(''); }
  if (stage) L.push(`- **Stage / resource status:** ${stage.text} ${stage.cite}`);
  if (drill?.n) L.push(`- **Drilling to date:** ${drill.n} holes for ${fmtNum(drill.m)} m [DrillHole aggregate]`);
  if (assay?.n) L.push(`- **Surface geochemistry:** ${assay.n} trench channel samples averaging ${round(assay.avg_cu, 3)}% Cu [AssaySample aggregate]`);
  L.push(`- **Definitive valuation:** Not available — no declared resource. See §6 for a labelled operator scenario.`);
  L.push('');

  // ── 2. Project overview ──
  L.push('## 2. Project overview');
  L.push('');
  { const p = proseSlot(narrative, 'overview'); if (p) { L.push(p); L.push(''); } }
  for (const t of ['property', 'mineralization', 'asset_stage']) {
    const f = factLine(byType, t);
    if (f) L.push(`- **${t.replace(/_/g, ' ')}:** ${f.text} ${f.cite}`);
  }
  L.push('');

  // ── 3. Geology & mineralization ──
  L.push('## 3. Geology & mineralization');
  L.push('');
  { const p = proseSlot(narrative, 'geology'); if (p) { L.push(p); L.push(''); } }
  for (const t of ['deposit_type', 'geophysics']) {
    const f = factLine(byType, t);
    if (f) L.push(`- **${t.replace(/_/g, ' ')}:** ${f.text} ${f.cite}`);
  }
  L.push('');
  const targets = [...byType.keys()].filter(k => k.startsWith('target_')).sort();
  if (targets.length) {
    L.push('### Defined targets');
    L.push('');
    for (const tk of targets) {
      const f = (byType.get(tk) || [])[0];
      if (f) L.push(`- **${tk.replace('target_', '').replace(/_/g, ' ')}:** ${f.value_text} [ExtractedFact:${f.id}${f.page_or_location ? `, ${f.page_or_location}` : ''}]`);
    }
    L.push('');
  }

  // ── 4. Exploration results ──
  L.push('## 4. Exploration results');
  L.push('');
  { const p = proseSlot(narrative, 'exploration'); if (p) { L.push(p); L.push(''); } }
  const drillSum = factLine(byType, 'drilling_summary');
  if (drillSum) L.push(`${drillSum.text} ${drillSum.cite}`);
  L.push('');
  if (intercepts.length) {
    L.push('**Best drill intercepts (Cu × width ranked):**');
    L.push('');
    L.push('| Interval | Width (m) | Cu % | Ag g/t | CuEq % |');
    L.push('|----------|-----------|------|--------|--------|');
    for (const i of intercepts) {
      let other = {};
      try { other = JSON.parse(i.other_grades_json || '{}'); } catch {}
      L.push(`| \`${i.sample_id}\` | ${fmtNum(i.width_m, 1)} | ${round(i.cu_pct, 3)} | ${i.ag_ppm != null ? round(i.ag_ppm, 1) : '—'} | ${other.cueq_pct != null ? round(other.cueq_pct, 3) : '—'} |`);
    }
    L.push('');
    L.push('_[AssaySample drill_core rows; CuEq per source-report formula]_');
    L.push('');
  }
  const trenchSum = factLine(byType, 'trenching_summary');
  if (trenchSum) { L.push(`${trenchSum.text} ${trenchSum.cite}`); L.push(''); }

  // ── 5. Comparable assets ──
  const comps = [...byType.keys()].filter(k => k.startsWith('comparable_')).sort();
  if (comps.length) {
    L.push('## 5. Comparable assets (analogues in the same belt)');
    L.push('');
    { const p = proseSlot(narrative, 'comparables'); if (p) { L.push(p); L.push(''); } }
    for (const ck of comps) {
      const f = (byType.get(ck) || [])[0];
      if (f) L.push(`- **${ck.replace('comparable_', '').replace(/_/g, ' ')}:** ${f.value_text} [ExtractedFact:${f.id}${f.page_or_location ? `, ${f.page_or_location}` : ''}]`);
    }
    L.push('');
    L.push('_Analogues establish the geological model and the in-ground value range for a successful porphyry discovery in this belt. They are not a prediction of Batamote tonnage or grade._');
    L.push('');
  }

  // ── 6. Valuation framework & operator scenario ──
  L.push('## 6. Valuation framework & indicative scenario');
  L.push('');
  L.push('No mineral resource has been declared for the project, therefore **no definitive (T1 in-situ or T2 DCF) valuation is produced** — the system does not assign tonnage or value to undefined mineralization. The figures below are a **conceptual operator scenario**, not a mineral resource and not a valuation opinion.');
  L.push('');
  if (cu || ag) {
    L.push(`**Metal price basis:** ${cu ? `Cu ${fmtUsd(cu.price_usd)}/lb` : ''}${cu && ag ? ' · ' : ''}${ag ? `Ag ${fmtUsd(ag.price_usd)}/oz` : ''} (${cu?.source || ag?.source || 'snapshot'}).`);
    L.push('');
  }
  if (scen.length) {
    L.push('| Scenario | Target tonnage | Cu grade | Indicative in-situ value |');
    L.push('|----------|----------------|----------|--------------------------|');
    for (const s of scen) {
      let a = {}, r = {};
      try { a = JSON.parse(s.assumptions_json || '{}'); } catch {}
      try { r = JSON.parse(s.result_json || '{}'); } catch {}
      const label = s.scenario.replace('operator_', '');
      L.push(`| ${label} | ${fmtNum(a.resource_tonnes)} t | ${round(a.grade_cu_pct, 3)}% | ${fmtUsd(r.in_situ_value_usd)} |`);
    }
    L.push('');
    L.push('> **CONCEPTUAL EXPLORATION TARGET — IMPORTANT.** The tonnage and grade above are an operator-supplied conceptual exploration target expressed as a range. They are conceptual in nature; there has been insufficient exploration to define a mineral resource; and it is uncertain whether further exploration will result in the target being delineated as a mineral resource. These figures must not be construed as a mineral resource or a mineral reserve, and the in-situ values are an order-of-magnitude indication of exploration optionality only.');
    L.push('');
  }

  // ── 7. Data inventory & QA/QC ──
  L.push('## 7. Data inventory & quality control');
  L.push('');
  L.push(`- **Documents catalogued:** ${fmtNum(docCount)} files across the project archive.`);
  L.push(`- **Text chunks indexed:** ${fmtNum(chunkCount)} for retrieval.`);
  if (drill?.n) L.push(`- **Structured drill holes:** ${drill.n}; **trench assays:** ${assay?.n || 0}; **drill intercepts:** ${intercepts.length}.`);
  const qa = factLine(byType, 'qa_qc');
  if (qa) L.push(`- **QA/QC:** ${qa.text} ${qa.cite}`);
  const dv = factLine(byType, 'data_verification');
  if (dv) L.push(`- **Data verification:** ${dv.text} ${dv.cite}`);
  L.push('');

  // ── 8. Risk register ──
  L.push('## 8. Risk register');
  L.push('');
  { const p = proseSlot(narrative, 'risk_intro'); if (p) { L.push(p); L.push(''); } }
  L.push('| Risk | Severity | Mitigation |');
  L.push('|------|----------|------------|');
  L.push('| No declared mineral resource (exploration risk) | High | Maiden resource drill program at El Choclo NW / Bata Sur |');
  L.push('| Grade continuity not yet demonstrated at depth | Medium | Infill + step-out drilling; clay-alteration vectoring |');
  L.push('| Metal price volatility | Medium | Long-dated optionality; copper structural demand |');
  L.push('| Permitting / surface rights (Sonora) | Medium | Confirm tenure & community agreements in legal DD |');
  L.push('| Data provenance (historic operators) | Low–Med | Teck data verified vs lab certificates; re-log key holes |');
  L.push('');

  // ── 9. Recommended work program ──
  L.push('## 9. Recommended work program');
  L.push('');
  const wp = factLine(byType, 'work_program');
  if (wp) { L.push(`${wp.text} ${wp.cite}`); L.push(''); }
  const recommendation = proseSlot(narrative, 'recommendation');
  if (recommendation) {
    L.push('_Indicative next-phase program (MadarX planning view, not from the technical report):_');
    L.push('');
    L.push(recommendation);
    L.push('');
  }

  // ── 10. Limitations ──
  L.push('## 10. Limitations & disclaimers');
  L.push('');
  L.push('Structured figures are transcribed/extracted from the project NI 43-101 technical report and archive; comparable-asset figures are from public S-K 1300 reports referenced therein. Metal prices are dated market snapshots. The operator scenario is conceptual and not a mineral resource. This memorandum is decision-support analysis, not a qualified person\'s valuation opinion, and does not constitute investment advice.');
  L.push('');
  L.push('---');
  L.push('_Generated by `mines_report_generate` (due_diligence_memo). Numeric claims trace to `mines_extracted_facts`, SQL aggregates, or persisted `mines_valuation_scenarios` rows._');

  return {
    markdown: L.join('\n'),
    facts_cited: [...byType.values()].reduce((a, v) => a + v.length, 0),
    comparables: comps.length,
    targets: targets.length,
    scenario_rows: scen.length,
  };
}

// ── pfs_readiness_report ───────────────────────────────────────────────────────
//
// A PFS-readiness GAP ASSESSMENT — NOT a Pre-Feasibility Study.
//
// A real PFS requires a Mineral Reserve derived from Measured & Indicated
// Resources (no Inferred), Modifying Factors, a mine plan, recovery testwork,
// and capex/opex to ±25% (AACE Class 4), each signed off by a competent QP/CP
// (docs/mines/pfs-best-practice-template.md, §15 is "the defining PFS section").
// Most assets in this archive are exploration-stage with no declared resource,
// so a PFS cannot honestly be produced. This report instead measures the asset
// against the PFS rubric: it inventories what data exists (SQL-grounded, cited),
// maps it to the NI 43-101 sections and the G0–G5 decision gates, and emits the
// critical-path gap list + work program needed to reach PFS confidence.
//
// Provenance invariant holds: every count/figure traces to a SQL row or
// aggregate; gate/section statuses are derived from data presence, not asserted;
// prose is agent-authored and number-scrubbed.

// Presence detectors keyed to PFS section / gate requirements. Each returns the
// evidence (fact rows / counts) so the report can cite what it found — and, when
// absent, name the specific missing data type rather than estimate it.
function assessPfsReadiness(db, projectId) {
  const factTypes = new Set(
    db.prepare('SELECT DISTINCT fact_type FROM mines_extracted_facts WHERE project_id = ?')
      .all(projectId).map((r) => r.fact_type),
  );
  const hasAnyFact = (...needles) =>
    [...factTypes].some((t) => needles.some((n) => t === n || t.startsWith(`${n}_`) || t.includes(n)));

  const drill = db.prepare('SELECT COUNT(*) n, SUM(total_depth_m) m FROM mines_drill_holes WHERE project_id = ?').get(projectId);
  const assay = db.prepare('SELECT COUNT(*) n FROM mines_assay_samples WHERE project_id = ?').get(projectId);
  const docCount = db.prepare('SELECT COUNT(*) n FROM mines_source_documents WHERE project_id = ?').get(projectId).n;
  const chunkCount = db.prepare('SELECT COUNT(*) n FROM mines_document_chunks WHERE project_id = ?').get(projectId).n;
  const targets = [...factTypes].filter((t) => t.startsWith('target_')).length;
  const comparables = [...factTypes].filter((t) => t.startsWith('comparable_')).length;

  // Valuation scenarios are NOT evidence of a declared resource. base/downside/
  // upside T1 in-situ runs and operator_* targets are indicative valuations on
  // conceptual/comparable tonnage — a declared Mineral Resource only exists when
  // an explicit resource-class FACT is present (block model accepted by a QP).
  const indicativeScenarios = db.prepare(
    "SELECT COUNT(*) n FROM mines_valuation_scenarios WHERE project_id = ? AND scenario NOT LIKE 'operator_%'"
  ).get(projectId).n;
  const conceptualScenarios = db.prepare(
    "SELECT COUNT(*) n FROM mines_valuation_scenarios WHERE project_id = ? AND scenario LIKE 'operator_%'"
  ).get(projectId).n;

  const hasResource = hasAnyFact('mineral_resource', 'resource_estimate', 'resource_statement', 'resource_classification', 'measured_indicated');
  const hasReserve = hasAnyFact('mineral_reserve', 'reserve_estimate', 'proven', 'probable');
  const hasMetTestwork = hasAnyFact('metallurg', 'recovery', 'met_testwork', 'processing');
  const hasMinePlan = hasAnyFact('mine_plan', 'production_schedule', 'mining_method', 'pit', 'geotech');
  const hasCostEstimate = hasAnyFact('capex', 'opex', 'cost_estimate', 'aisc');
  const hasMarket = hasAnyFact('market', 'price_deck', 'offtake') || db.prepare('SELECT COUNT(*) n FROM mines_market_snapshots').get().n > 0;
  const hasQaQc = factTypes.has('qa_qc');
  const hasDataVerification = factTypes.has('data_verification');
  const hasGeology = hasAnyFact('deposit_type', 'mineralization', 'geophysics', 'property') || targets > 0;
  const hasDrilling = (drill?.n || 0) > 0 || hasAnyFact('drilling_summary', 'trenching_summary');

  return {
    factTypes, drill, assay, docCount, chunkCount, targets, comparables,
    indicativeScenarios, conceptualScenarios,
    hasResource, hasReserve, hasMetTestwork, hasMinePlan, hasCostEstimate,
    hasMarket, hasQaQc, hasDataVerification, hasGeology, hasDrilling,
  };
}

// Status taxonomy for gates/sections, rendered as labelled cells. Text-only —
// emoji are stripped on artifact store (createArtifact) and banned in UI output.
const ST = {
  ready: '**Ready**',
  partial: '**Partial**',
  gap: '**Gap**',
  blocked: '**Blocked**',
};

function buildPfsReadinessReport(db, project, narrative = {}) {
  const projectId = project.id;
  const a = assessPfsReadiness(db, projectId);
  const L = [];
  const today = new Date().toISOString().slice(0, 10);

  // The load-bearing verdict: PFS feasibility is gated on a declared resource.
  // No resource → no reserve → no PFS, regardless of how much exploration data exists.
  const pfsFeasible = a.hasResource && a.hasReserve;

  // ── Header ── (no leading H1 — the artifact window/card renders the title)
  L.push(`**Prepared by:** MadarX OS · Mines  |  **Date:** ${today}  |  **Project:** \`${projectId}\``);
  L.push(`**Asset stage:** ${project.asset_stage}  |  **Commodity:** ${project.commodity || 'n/a'}  |  **Jurisdiction:** ${project.state_province || 'n/a'}, ${project.country || 'n/a'}`);
  L.push('');
  L.push('> **This is a PFS-READINESS ASSESSMENT, not a Pre-Feasibility Study.** A PFS requires a Mineral Reserve converted from Measured & Indicated Resources (no Inferred), with Modifying Factors, a mine plan, recovery testwork, and capex/opex to ±25% (AACE Class 4), each signed off by a competent QP/CP. This report measures the asset against that standard, inventories the data that exists (every count traces to a database row), and lists the gaps and work program required to reach PFS confidence. It does **not** declare a resource, a reserve, or any figure the source data does not contain.');
  L.push('');

  // ── 1. Readiness verdict ──
  L.push('## 1. Readiness verdict');
  L.push('');
  { const p = proseSlot(narrative, 'verdict'); if (p) { L.push(p); L.push(''); } }
  if (pfsFeasible) {
    L.push('**A Mineral Reserve appears to exist in the dataset.** A PFS may be supportable — proceed to the section-by-section coverage below to confirm each discipline meets PFS confidence, then route reserve/economics work to the valuation analyst and a competent QP.');
  } else if (a.hasResource && !a.hasReserve) {
    L.push('**A Mineral Resource is present but no Mineral Reserve has been declared.** A PFS is **not yet supportable**. The defining PFS step — conversion of Measured & Indicated Resources to a Proven/Probable Reserve via Modifying Factors (template §15) — has not been done. This is the critical path.');
  } else {
    L.push('**No Mineral Resource is declared for this asset.** A PFS is **not supportable and must not be produced.** Per the PFS contract (template §1, checklist gate G3) reserves derive from Measured & Indicated Resources only — and there is no resource to convert. The asset sits at an **exploration / scoping** confidence level. The work below maps a path from exploration data to a maiden resource, and from there toward PFS.');
    if (a.conceptualScenarios > 0) {
      L.push('');
      L.push(`> The project carries ${a.conceptualScenarios} conceptual operator scenario(s) in \`mines_valuation_scenarios\`. These are exploration-target order-of-magnitude figures, **not** a mineral resource, and cannot seed a reserve or a PFS economic case.`);
    }
  }
  L.push('');

  // ── 2. Decision-gate status (G0–G5) ──
  // Gate logic derived from data presence — RPEEE / reserve / economics gates
  // are BLOCKED until their prerequisite gate passes (a PFS is sequential).
  const g1 = a.hasResource ? ST.ready : (a.hasDrilling && (a.hasQaQc || a.hasDataVerification)) ? ST.partial : a.hasGeology ? ST.partial : ST.gap;
  const g2 = (a.hasMinePlan && a.hasMetTestwork) ? ST.ready : (a.hasMinePlan || a.hasMetTestwork) ? ST.partial : ST.gap;
  const g3 = a.hasReserve ? ST.ready : a.hasResource ? ST.gap : ST.blocked;
  const g4 = (a.hasReserve && a.hasCostEstimate) ? ST.ready : a.hasReserve ? ST.partial : ST.blocked;
  const g5 = pfsFeasible ? ST.partial : ST.blocked;
  L.push('## 2. Decision-gate status');
  L.push('');
  L.push('_Gates are sequential (template §2). A downstream gate is `Blocked` until its prerequisite passes — no amount of work on a later gate substitutes for the gate before it._');
  L.push('');
  L.push('| Gate | Requirement | Status |');
  L.push('|------|-------------|--------|');
  L.push(`| **G0** Study launch | Charter, code, scope, QPs, budget, gates fixed | ${ST.gap} (no study charter in dataset) |`);
  L.push(`| **G1** Geological foundation | DB validated; QA/QC documented; M/I/I classified; RPEEE shown | ${g1} |`);
  L.push(`| **G2** Options / trade-off | Mining method, process route, tailings, power/water selected | ${g2} |`);
  L.push(`| **G3** Reserve declaration | M&I only; no Inferred; dilution & losses; Modifying Factors | ${g3} |`);
  L.push(`| **G4** Estimate & economics | Capex/opex ±25%; ~15% contingency; reserve-based model | ${g4} |`);
  L.push(`| **G5** Assurance & disclosure | Risk register, fatal-flaw + independent review, QP sign-offs | ${g5} |`);
  L.push('');

  // ── 3. Section-by-section coverage (NI 43-101 / PFS body) ──
  // Each row: the section, what data is present (cited counts), and a status.
  const sec = (s) => (s ? ST.partial : ST.gap);
  const coverage = [
    ['1–3 · Summary / Introduction / Reliance', a.docCount > 0, `${fmtNum(a.docCount)} source documents catalogued`],
    ['4 · Property Description & Location', a.factTypes.has('property'), a.factTypes.has('property') ? 'property fact present' : 'no tenure/location fact extracted'],
    ['5 · Accessibility, Climate, Infrastructure', false, 'not extracted — requires site/infrastructure data'],
    ['6 · History', a.factTypes.has('deposit_type') || a.docCount > 0, 'historic archive present'],
    ['7 · Geological Setting & Mineralization', a.hasGeology, a.hasGeology ? `geology facts + ${a.targets} target(s)` : 'no geology facts'],
    ['8 · Deposit Types', a.factTypes.has('deposit_type'), a.factTypes.has('deposit_type') ? 'deposit_type fact present' : 'no deposit_type fact'],
    ['9–10 · Exploration & Drilling', a.hasDrilling, `${a.drill?.n || 0} drill holes; drilling/trenching summaries`],
    ['11–12 · Sampling, Analyses, Data Verification', a.hasQaQc || a.hasDataVerification, `${a.assay?.n || 0} assay samples; QA/QC=${a.hasQaQc}; verification=${a.hasDataVerification}`],
    ['13 · Mineral Processing & Metallurgical Testing', a.hasMetTestwork, a.hasMetTestwork ? 'testwork present' : 'no metallurgical testwork — required for recovery model'],
    ['14 · Mineral Resource Estimate', a.hasResource, a.hasResource ? 'resource present' : 'NO RESOURCE — blocks reserve & economics'],
    ['15 · Mineral Reserve Estimate (defining PFS section)', a.hasReserve, a.hasReserve ? 'reserve present' : 'NO RESERVE — the PFS cannot exist without this'],
    ['16 · Mining Methods', a.hasMinePlan, a.hasMinePlan ? 'mine-plan/geotech facts present' : 'no mine plan / geotechnical model'],
    ['17 · Recovery Methods', a.hasMetTestwork, a.hasMetTestwork ? 'derived from testwork' : 'blocked on metallurgical testwork'],
    ['18 · Project Infrastructure', false, 'not extracted — power/water/tailings/access required'],
    ['19 · Market Studies & Contracts', a.hasMarket, a.hasMarket ? 'market snapshots/facts present' : 'no price deck / offtake'],
    ['20 · Environmental, Permitting, Social', false, 'not extracted — baseline/permitting register required'],
    ['21 · Capital & Operating Costs', a.hasCostEstimate, a.hasCostEstimate ? 'cost facts present' : 'no capex/opex estimate (need AACE Class 4)'],
    ['22 · Economic Analysis', a.hasReserve && a.hasCostEstimate, 'requires a reserve-based model'],
    ['23 · Adjacent Properties', a.comparables > 0, `${a.comparables} comparable asset(s)`],
    ['25–26 · Interpretation, Conclusions & Recommendations', a.docCount > 0, 'authored from the above'],
  ];
  L.push('## 3. PFS section coverage (NI 43-101 body)');
  L.push('');
  L.push('| Section | Status | Evidence in dataset |');
  L.push('|---------|--------|---------------------|');
  for (const [name, present, evidence] of coverage) {
    const status = name.startsWith('14 ') ? (a.hasResource ? ST.ready : ST.gap)
      : name.startsWith('15 ') ? (a.hasReserve ? ST.ready : a.hasResource ? ST.gap : ST.blocked)
        : sec(present);
    L.push(`| ${name} | ${status} | ${evidence} |`);
  }
  L.push('');
  L.push('_Status is computed from `mines_extracted_facts` types and SQL aggregates — a `Gap` means the data type is absent from the database, not that it was judged inadequate._');
  L.push('');

  // ── 4. The PFS contract ──
  L.push('## 4. The PFS contract (must all hold to declare a PFS)');
  L.push('');
  const contract = [
    ['Reserves from Measured & Indicated only (no Inferred)', a.hasReserve],
    ['Dilution & mining losses incorporated', a.hasReserve && a.hasMinePlan],
    ['Modifying Factors formally applied & documented', a.hasReserve],
    ['Capex/opex ±25%, ~15% contingency, AACE Class 4', a.hasCostEstimate],
    ['Reserve-based financial model, positive across a downside', a.hasReserve && a.hasCostEstimate],
    ['Each discipline signed off by a competent QP/CP', pfsFeasible],
  ];
  for (const [item, met] of contract) L.push(`- [${met ? 'x' : ' '}] ${item}`);
  L.push('');
  if (!pfsFeasible) L.push('Until every box is checked, the asset is **pre-PFS**. The earliest unchecked box on the critical path is where work must start (see §6).');
  L.push('');

  // ── 5. Data inventory (SQL-grounded) ──
  L.push('## 5. Data inventory');
  L.push('');
  L.push(`- **Documents catalogued:** ${fmtNum(a.docCount)} · **text chunks indexed:** ${fmtNum(a.chunkCount)}`);
  L.push(`- **Structured drill holes:** ${a.drill?.n || 0}${(a.drill?.m) ? ` for ${fmtNum(a.drill.m)} m` : ''} · **assay samples:** ${a.assay?.n || 0}`);
  L.push(`- **Extracted fact types:** ${a.factTypes.size} · **defined targets:** ${a.targets} · **comparables:** ${a.comparables}`);
  L.push(`- **Valuation scenarios:** ${a.indicativeScenarios} indicative (T1/T2 runs) · ${a.conceptualScenarios} conceptual operator-target — neither is a declared resource`);
  L.push('');
  L.push('_Counts are read directly from `mines_source_documents`, `mines_document_chunks`, `mines_drill_holes`, `mines_assay_samples`, `mines_extracted_facts`, and `mines_valuation_scenarios`._');
  L.push('');

  // ── 6. Critical-path gap list ──
  // Ordered by gate sequence — the first missing prerequisite is the next move.
  L.push('## 6. Critical-path gaps (ordered)');
  L.push('');
  { const p = proseSlot(narrative, 'gap_intro'); if (p) { L.push(p); L.push(''); } }
  const gaps = [];
  if (!a.hasResource) gaps.push('**Maiden Mineral Resource estimate** — the single hard blocker. Requires sufficient drill density, QA/QC-validated assays, density data, domaining, a block model, classification (M/I/I), and RPEEE within a constraining shape. Until this exists, no reserve, no economics, no PFS.');
  if (!a.hasMetTestwork) gaps.push('**Metallurgical testwork** — representative samples across lithology/alteration/oxidation/depth/grade; a recovery model by domain (not a single point). Blocks §13/§17.');
  if (a.hasResource && !a.hasMinePlan) gaps.push('**Mine plan & geotechnical model** — method/pit-configuration trade-off, slope/support parameters, production schedule. Required before reserve conversion.');
  if (a.hasResource && !a.hasReserve) gaps.push('**Reserve conversion (§15)** — apply Modifying Factors to M&I, incorporate dilution & mining losses, derive Proven/Probable. This is the defining PFS step.');
  if (!a.hasCostEstimate) gaps.push('**Capex/opex estimate to ±25% (AACE Class 4)** — formal Basis of Estimate, vendor budget quotes on major items, ~15% risk-based contingency. Blocks §21/§22.');
  gaps.push('**Infrastructure, water & tailings study** — power/water/access; site water balance; tailings option + GISTM alignment; confirm no fatal flaw.');
  gaps.push('**Environmental / permitting / social baseline** — multi-season baseline, permitting register on the critical path, closure cost flowed to the model.');
  gaps.push('**Competent QP/CP sign-off per discipline** — and an independent / peer technical review before any disclosure.');
  gaps.forEach((g, i) => L.push(`${i + 1}. ${g}`));
  L.push('');

  // ── 7. Recommended work program ──
  L.push('## 7. Recommended work program to reach PFS');
  L.push('');
  const wp = db.prepare(
    "SELECT id, value_text, page_or_location FROM mines_extracted_facts WHERE project_id = ? AND fact_type = 'work_program' LIMIT 1"
  ).get(projectId);
  if (wp) { L.push(`${wp.value_text} [ExtractedFact:${wp.id}${wp.page_or_location ? `, ${wp.page_or_location}` : ''}]`); L.push(''); }
  const program = proseSlot(narrative, 'work_program') || proseSlot(narrative, 'recommendation');
  if (program) {
    L.push('_Indicative phased program (MadarX planning view, not from the technical report):_');
    L.push('');
    L.push(program);
    L.push('');
  } else if (!wp) {
    L.push('Phase the program to the gate sequence above: (1) resource-definition drilling → maiden resource (G1); (2) metallurgical testwork + mining/geotech trade-off (G2); (3) reserve conversion with Modifying Factors (G3); (4) Class 4 cost estimate + reserve-based model (G4); (5) risk, independent review, QP sign-off (G5). Budget and schedule each phase before launch.');
    L.push('');
  }

  // ── 8. Limitations ──
  L.push('## 8. Limitations & disclaimers');
  L.push('');
  L.push('This is a readiness assessment generated by autonomous agents from the project database and document archive. Statuses reflect the **presence** of data types, not a competent person\'s judgement of their adequacy. The absence of a resource/reserve in the database is reported as a gap, never filled with an estimate. This document is decision-support analysis; it is not a Pre-Feasibility Study, not a qualified person\'s opinion, and does not constitute investment advice. Producing an actual PFS requires the work program above and competent-person sign-off under the applicable reporting code (NI 43-101 / S-K 1300 / JORC).');
  L.push('');
  L.push('---');
  L.push('_Generated by `mines_report_generate` (pfs_readiness_report). Gate/section statuses derive from `mines_extracted_facts` types and SQL aggregates; no figure is asserted that the source data does not contain. Rubric: `docs/mines/pfs-best-practice-template.md` + `pfs-best-practice-checklist.md`._');

  return {
    markdown: L.join('\n'),
    pfs_feasible: pfsFeasible,
    has_resource: a.hasResource,
    has_reserve: a.hasReserve,
    gaps_count: gaps.length,
    fact_types: a.factTypes.size,
    documents: a.docCount,
  };
}

// ── adapter ───────────────────────────────────────────────────────────────────

/**
 * @param {object} input - { project_id, report_kind, language?, send_email?, ... }
 * @returns {Promise<object>}
 */
export async function minesReportGenerate(input, options = {}) {
  const project_id = input?.project_id;
  const report_kind = input?.report_kind;
  if (!project_id) return { status: 'error', error: 'project_id_required' };
  if (!report_kind) return { status: 'error', error: 'report_kind_required' };

  if (input?.send_email) {
    // External delivery is deferred — it must route through the email_send tool's
    // irreversible_external gate, which is wired in a later phase.
    return { status: 'error', error: 'email_delivery_not_enabled', report_kind };
  }

  if (!SUPPORTED_KINDS.has(report_kind)) {
    return { status: 'not_implemented', report_kind, supported: [...SUPPORTED_KINDS] };
  }

  const db = getDb();
  const project = db.prepare(
    'SELECT id, os_project_id, name, slug, asset_stage, commodity, deposit_type, country, state_province, default_space_id FROM mines_projects WHERE id = ?'
  ).get(project_id);
  if (!project) return { status: 'error', error: 'project_not_found' };

  let markdown;
  let meta = {};
  let title;
  let artifactKind = 'markdown';

  // LLM-authored, number-free prose slots for this report (figures always come
  // from cited SQL rows inside the builders; proseSlot redacts leaked numbers).
  const narrative = (input?.narrative && typeof input.narrative === 'object') ? input.narrative : {};

  if (report_kind === 'project_package') {
    markdown = buildProjectPackageHtml(project);
    artifactKind = 'mines_project_package';
    title = `Diligence Package — ${project.name}`;
    meta = { whole_page: true };
  } else if (report_kind === 'investor_presentation') {
    // The HTML deck reads prose slots via esc() (no number-stripping), so scrub
    // the LLM-authored slots here to preserve the number-free-prose invariant.
    const pres = buildInvestorPresentation(db, project, scrubNarrative(narrative));
    markdown = pres.html;
    artifactKind = 'mines_investor_presentation';
    title = `Investor Presentation — ${project.name}`;
    meta = pres.meta;
  } else if (report_kind === 'data_quality_report') {
    const dq = getProjectDataQuality(project_id);
    markdown = buildDataQualityMarkdown(project, dq, narrative);
    title = `Data Quality Report — ${project.name}`;
    meta = {
      completeness_score: dq.completeness_score,
      documents: dq.totals.documents,
      chunks: dq.totals.chunks,
      spatial_assets: dq.spatial_assets_count,
    };
  } else if (report_kind === 'due_diligence_memo') {
    const memo = buildDueDiligenceMemo(db, project, narrative);
    markdown = memo.markdown;
    title = `Investment Diligence Memo — ${project.name}`;
    meta = {
      facts_cited: memo.facts_cited,
      comparables: memo.comparables,
      targets: memo.targets,
      scenario_rows: memo.scenario_rows,
    };
  } else if (report_kind === 'pfs_readiness_report') {
    const rep = buildPfsReadinessReport(db, project, narrative);
    markdown = rep.markdown;
    title = `PFS-Readiness Assessment — ${project.name}`;
    meta = {
      pfs_feasible: rep.pfs_feasible,
      has_resource: rep.has_resource,
      has_reserve: rep.has_reserve,
      gaps_count: rep.gaps_count,
      fact_types: rep.fact_types,
      documents: rep.documents,
    };
  } else {
    const memo = await buildInterpretationMemo(db, project, narrative);
    markdown = memo.markdown;
    title = `Interpretation Memo — ${project.name}`;
    meta = {
      numeric_claims_count: memo.numeric_claims_count,
      chunk_citations_count: memo.chunk_citations_count,
      retrieval_mode: memo.retrieval_mode,
    };
  }

  // Resolve the target dashboard space: explicit override wins, else the
  // project's configured default space. fallback:null so an unresolved space
  // falls through to recordArtifact's agent/default resolution + skips the pin.
  const targetSpace = resolveTargetSpace([input?.space_id, project.default_space_id], { fallback: null });
  const osProjectId = resolveOsProjectId(db, project_id);

  // Regenerate = REPLACE, not stack. The report title is deterministic per
  // (kind, project), so any prior artifact with the same title in the same
  // scope is the previous edition of this exact report. Delete it before
  // writing the fresh one so regeneration leaves exactly one current copy.
  // We cannot use createArtifact's dedupe (it short-circuits and keeps the
  // STALE content); here we want the NEW content to win. Scope the match to
  // the target space when one resolved, else to the OS project — matching the
  // scoping createArtifact will apply to the new row.
  try {
    const priors = targetSpace
      ? db.prepare('SELECT id FROM artifacts WHERE title = ? AND space_id = ?').all(title, targetSpace)
      : db.prepare('SELECT id FROM artifacts WHERE title = ? AND project_id = ?').all(title, osProjectId || 'personal');
    for (const p of priors) {
      try { deleteArtifact(p.id); } catch { /* best-effort cleanup */ }
    }
  } catch { /* non-fatal — fall through to create */ }

  const artifact = createArtifact({
    title,
    kind: artifactKind,
    content: markdown,
    project_id: osProjectId,
    agent_id: options.agent_id || null,
    space_id: targetSpace,
    dedupe_key: '', // allow regenerating the report (prior copy already replaced above)
  });

  // Pin into the space so the artifact surfaces on the dashboard board even when
  // a different project is active (pinned_to_space_at bypasses the project filter).
  if (targetSpace && !artifact.deduped) {
    try { moveArtifactToSpace(artifact.artifact_id, targetSpace); } catch { /* non-fatal */ }
  }

  try {
    appendComplianceAudit({
      user_id: options.acting_on_behalf_of_user_id || options.user_id || 'system',
      org_id: 'personal-org',
      action: 'mines.report.generated',
      status: 'success',
      summary: `Generated ${report_kind} for ${project.name}`,
      action_target_id: artifact.artifact_id,
      action_target_type: 'artifact',
      meta: { project_id, report_kind, artifact_id: artifact.artifact_id, ...meta },
    });
  } catch { /* non-fatal */ }

  return {
    status: 'ok',
    artifact_id: artifact.artifact_id,
    report_kind,
    space_id: targetSpace,
    chunk_citations_count: meta.chunk_citations_count ?? 0,
    ...meta,
  };
}
