// lib/tools/adapters/mines/extract-facts.js
//
// mines_extract_facts — the LLM extraction step that turns parsed document
// chunks into structured, provenance-complete mines_extracted_facts rows.
//
// This is the ONE place an LLM reads the document corpus and produces facts.
// Everything downstream (mines_report_generate, mines_valuation_run) is
// deterministic and only assembles / computes from these rows — the model never
// authors numbers in a report. Here the model proposes facts; this adapter then
// REJECTS any fact that does not cite a real chunk id with a verbatim quote that
// actually appears in that chunk. No citation → no persisted fact. That guard is
// what keeps the pipeline honest: the model cannot invent a grade, tonnage, or
// claim that is not anchored in the source document.
//
// Hard rule baked into the prompt: never emit resource_estimate unless the
// document explicitly declares a mineral resource/reserve with tonnage + grade.
// Early-stage exploration with no declared resource → that fact is omitted, so
// the system never assigns tonnage to undefined mineralization.
//
// Risk: medium. No external network calls except the Anthropic Messages API
// (governed by the registry gate + agent tool permission).

import { getDb, newId, nowIso } from '../../../db.js';
import { chatWithClaude } from '../../../claude.js';
import { authorJson } from '../../../engine-author.js';
import { appendComplianceAudit } from '../../../compliance-audit.js';

// Character budget for the chunk window handed to the model. ~32k chars ≈ 8k
// tokens of source text — enough to cover the salient sections of a typical
// 43-101 within one call while keeping cost bounded.
const DEFAULT_CHAR_BUDGET = Number(process.env.MINES_EXTRACT_CHAR_BUDGET) || 90000;
const DEFAULT_MAX_CHUNKS = Number(process.env.MINES_EXTRACT_MAX_CHUNKS) || 120;

// High-signal terms — chunks matching these are prioritised into the window so
// the model sees geology / economics / QA-QC content even in a long report.
const SIGNAL_TERMS = [
  'porphyry', 'deposit', 'mineraliz', 'mineralis', 'chalcocite', 'breccia',
  'drill', 'sondaje', 'barren', 'trench', 'trinchera', 'channel',
  'grade', 'ley', 'cu ', 'copper', 'cobre', 'au ', 'gold', 'oro', 'silver', 'plata',
  'resource', 'recurso', 'reserve', 'reserva', 'tonn', 'tonelad',
  'target', 'objetivo', 'qa/qc', 'qaqc', 'quality control', 'assay', 'ensaye',
  'la caridad', 'cananea', 'la colorada', 'geophys', 'magnetic', 'gravity',
  'recommend', 'recomend', 'work program', 'programa',
];

const STANDARD_FACT_TYPES = [
  'property', 'asset_stage', 'deposit_type', 'mineralization',
  'drilling_summary', 'trenching_summary', 'geophysics', 'qa_qc',
  'data_verification', 'work_program', 'resource_estimate',
];

const EMIT_TOOL = {
  name: 'emit_mining_facts',
  description:
    'Emit the structured facts extracted from the supplied document chunks. Call exactly once.',
  input_schema: {
    type: 'object',
    properties: {
      facts: {
        type: 'array',
        description: 'One entry per distinct fact found in the chunks. Omit any fact not supported by the text.',
        items: {
          type: 'object',
          properties: {
            fact_type: {
              type: 'string',
              description:
                'Canonical key. Use one of: property, asset_stage, deposit_type, mineralization, drilling_summary, trenching_summary, geophysics, qa_qc, data_verification, work_program, resource_estimate. For each defined exploration target use target_<slug> (e.g. target_el_choclo_nw). For each comparable/analogue deposit use comparable_<slug> (e.g. comparable_la_caridad).',
            },
            value_text: {
              type: 'string',
              description: 'The fact stated concisely in English (preserve Spanish technical terms in parentheses on first use). Quote figures exactly as written.',
            },
            source_chunk_id: {
              type: 'string',
              description: 'The exact [chunk_id] of the chunk this fact came from. Must be one of the provided ids.',
            },
            source_quote: {
              type: 'string',
              description: 'A short verbatim span (10–200 chars) copied EXACTLY from that chunk that supports the fact. Must appear in the chunk text character-for-character.',
            },
            confidence: {
              type: 'number',
              description: '0..1 — how directly the chunk supports the fact. 0.9+ for explicit statements, lower for inferred.',
            },
          },
          required: ['fact_type', 'value_text', 'source_chunk_id', 'source_quote'],
        },
      },
    },
    required: ['facts'],
  },
};

function normalize(s) {
  return String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[‘’]/g, "'").trim();
}

/** Resolve which source document to extract from. */
function resolveDocument(db, projectId, sourceDocumentId) {
  if (sourceDocumentId) {
    return db.prepare(
      'SELECT id, file_name, page_count FROM mines_source_documents WHERE id = ? AND project_id = ?'
    ).get(sourceDocumentId, projectId);
  }
  // Prefer the authoritative technical report (NI 43-101 / S-K 1300) by filename
  // — its category is unreliable in the archive, so match on the name. Only
  // fall back to the most-chunked document when no such report exists.
  const report = db.prepare(`
    SELECT d.id, d.file_name, d.page_count, COUNT(c.id) AS chunk_count
    FROM mines_source_documents d
    JOIN mines_document_chunks c ON c.source_document_id = d.id
    WHERE d.project_id = ?
      AND (
        d.file_name LIKE '%43-101%' OR d.file_name LIKE '%43_101%'
        OR lower(d.file_name) LIKE '%ni 43%' OR lower(d.file_name) LIKE '%s-k 1300%'
        OR lower(d.file_name) LIKE '%technical report%'
      )
    GROUP BY d.id
    ORDER BY chunk_count DESC
    LIMIT 1
  `).get(projectId);
  if (report) return report;

  return db.prepare(`
    SELECT d.id, d.file_name, d.page_count, COUNT(c.id) AS chunk_count
    FROM mines_source_documents d
    JOIN mines_document_chunks c ON c.source_document_id = d.id
    WHERE d.project_id = ?
    GROUP BY d.id
    ORDER BY chunk_count DESC
    LIMIT 1
  `).get(projectId);
}

/**
 * Select a representative, budget-bounded window of chunks for the document.
 * Strategy: prioritise chunks matching high-signal terms, then page-spread fill
 * the remaining budget so coverage spans the whole report — not just page 1.
 */
function selectChunks(allChunks, { charBudget, maxChunks }) {
  const scored = allChunks.map((c) => {
    const hay = normalize(c.text);
    let score = 0;
    for (const t of SIGNAL_TERMS) if (hay.includes(t)) score += 1;
    return { ...c, _score: score };
  });

  const chosen = [];
  let used = 0;
  const take = (c) => {
    if (chosen.find((x) => x.id === c.id)) return;
    const len = String(c.text || '').length;
    if (used + len > charBudget || chosen.length >= maxChunks) return;
    chosen.push(c);
    used += len;
  };

  // Pass 1: highest-signal chunks first.
  for (const c of [...scored].sort((a, b) => b._score - a._score || a.page - b.page)) {
    if (c._score > 0) take(c);
  }
  // Pass 2: page-spread fill for coverage of the rest.
  const byPage = [...scored].sort((a, b) => (a.page ?? 0) - (b.page ?? 0) || a.chunk_index - b.chunk_index);
  const stride = Math.max(1, Math.floor(byPage.length / Math.max(1, maxChunks)));
  for (let i = 0; i < byPage.length; i += stride) take(byPage[i]);

  return chosen.sort((a, b) => (a.page ?? 0) - (b.page ?? 0) || a.chunk_index - b.chunk_index);
}

function buildPrompt(project, doc, chunks) {
  const system = [
    'You are a mining geology and resource analyst extracting structured facts from a technical document (often an NI 43-101 or S-K 1300 report, frequently Spanish-first).',
    '',
    'Rules — follow exactly:',
    '1. Extract ONLY facts that are explicitly supported by the supplied chunks. Never use outside knowledge. Never infer numbers.',
    '2. Every fact MUST cite the exact [chunk_id] it came from and include a verbatim source_quote copied character-for-character from that chunk.',
    '3. Quote figures (grades, widths, tonnages, counts, prices) exactly as written in the source.',
    '4. CRITICAL: do NOT emit a resource_estimate fact unless the document explicitly declares a mineral RESOURCE or RESERVE with tonnage and grade for THIS property. Early-stage exploration with no declared resource → omit resource_estimate entirely. Do not assign tonnage to undefined mineralization.',
    '5. Use target_<slug> for each defined exploration target and comparable_<slug> for each analogue/comparable deposit described.',
    '6. Preserve Spanish technical terms in parentheses on first use; write value_text in English.',
    '7. Emit your result by calling emit_mining_facts exactly once. If a standard fact type is absent from the text, omit it — do not guess.',
  ].join('\n');

  const header = `PROJECT: ${project.name} (${project.commodity || 'commodity n/a'}, stage: ${project.asset_stage || 'n/a'})\nDOCUMENT: ${doc.file_name}\n\nCHUNKS (each tagged with its [chunk_id] and page):\n`;
  const body = chunks.map((c) =>
    `[${c.id}] (page ${c.page ?? 'n/a'})\n${String(c.text || '').replace(/\s+/g, ' ').trim()}`
  ).join('\n\n');

  const user = `${header}\n${body}\n\nExtract the structured facts now by calling emit_mining_facts. Standard fact types to look for: ${STANDARD_FACT_TYPES.join(', ')}, plus target_<slug> and comparable_<slug>.`;
  return { system, user };
}

function toolUseFrom(response) {
  return (response?.content || []).find((b) => b?.type === 'tool_use' && b.name === 'emit_mining_facts') || null;
}

/**
 * @param {object} input { project_id, source_document_id?, max_chunks?, char_budget?, approval_id? }
 * @param {object} options { agent_id?, user_id?, acting_on_behalf_of_user_id? }
 */
export async function minesExtractFacts(input, options = {}) {
  const projectId = input?.project_id;
  if (!projectId) return { status: 'error', error: 'project_id_required' };

  const db = getDb();
  const project = db.prepare(
    'SELECT id, name, commodity, asset_stage FROM mines_projects WHERE id = ?'
  ).get(projectId);
  if (!project) return { status: 'error', error: 'project_not_found' };

  const doc = resolveDocument(db, projectId, input?.source_document_id || null);
  if (!doc) return { status: 'error', error: 'no_extractable_document', reason: 'No document with indexed chunks found for this project. Run mines_data_import + mines_document_extract first.' };

  const allChunks = db.prepare(
    'SELECT id, page, chunk_index, text FROM mines_document_chunks WHERE source_document_id = ? ORDER BY page, chunk_index'
  ).all(doc.id);
  if (allChunks.length === 0) {
    return { status: 'error', error: 'no_chunks', document_id: doc.id, reason: 'Document has no extracted text chunks.' };
  }

  const charBudget = Number(input?.char_budget) || DEFAULT_CHAR_BUDGET;
  const maxChunks = Number(input?.max_chunks) || DEFAULT_MAX_CHUNKS;
  const window = selectChunks(allChunks, { charBudget, maxChunks });
  const chunkById = new Map(window.map((c) => [c.id, c]));

  const { system, user } = buildPrompt(project, doc, window);

  // The LLM step is engine-abstracted (re-architecture): on the `anthropic`
  // engine authorJson does a forced-tool emit; on `claude-code` (and other local
  // engines) it instructs a fenced-JSON response and validates with bounded
  // retries. `options.chat` (used by unit tests) injects a chatWithClaude-shaped
  // fake and keeps the legacy forced-tool path so those tests stay deterministic.
  // The citation validation below is the real provenance guard regardless of
  // engine — uncited / unverifiable facts are always rejected.
  const engine = options.engine || 'claude-code';
  let proposed = [];
  let model = null;
  try {
    if (typeof options.chat === 'function') {
      const response = await options.chat(
        [{ role: 'user', content: user }],
        system,
        [EMIT_TOOL],
        { max_tokens: 8192, temperature: 0, tool_choice: { type: 'tool', name: 'emit_mining_facts' } }
      );
      const toolUse = toolUseFrom(response);
      proposed = Array.isArray(toolUse?.input?.facts) ? toolUse.input.facts : [];
      model = response?.model || null;
    } else {
      const out = await authorJson({
        engine,
        schema: EMIT_TOOL.input_schema,
        system,
        user,
        label: 'mining facts',
        temperature: 0,
        max_tokens: 8192,
      });
      proposed = Array.isArray(out?.facts) ? out.facts : [];
    }
  } catch (err) {
    return { status: 'error', error: 'llm_unavailable', reason: err?.message || String(err) };
  }

  // ── Citation validation: every fact must cite a real chunk + a quote that
  //    actually appears in that chunk. Uncited / unverifiable → rejected. ──
  const accepted = [];
  const rejected = [];
  for (const f of proposed) {
    const factType = String(f?.fact_type || '').trim().toLowerCase().replace(/[^a-z0-9_]/g, '_');
    const valueText = String(f?.value_text || '').trim();
    const chunkId = String(f?.source_chunk_id || '').trim();
    const quote = String(f?.source_quote || '').trim();

    if (!factType || !valueText) { rejected.push({ fact_type: factType, reason: 'missing_type_or_value' }); continue; }
    const chunk = chunkById.get(chunkId);
    if (!chunk) { rejected.push({ fact_type: factType, reason: 'uncited_or_unknown_chunk', chunk_id: chunkId }); continue; }
    if (quote.length < 6) { rejected.push({ fact_type: factType, reason: 'quote_too_short' }); continue; }

    // Verify the quote (or a meaningful fragment of it) appears in the chunk.
    const hay = normalize(chunk.text);
    const q = normalize(quote);
    const fragment = q.slice(0, 48);
    const verified = hay.includes(q) || (fragment.length >= 12 && hay.includes(fragment));
    if (!verified) { rejected.push({ fact_type: factType, reason: 'quote_not_found_in_chunk', chunk_id: chunkId }); continue; }

    const conf = Number(f?.confidence);
    accepted.push({
      fact_type: factType,
      value_text: valueText,
      source_document_id: doc.id,
      source_chunk_id: chunk.id,
      source_quote: quote.slice(0, 400),
      page_or_location: chunk.page != null ? `page ${chunk.page}` : null,
      confidence: Number.isFinite(conf) ? Math.max(0, Math.min(1, conf)) : 0.85,
    });
  }

  // Dedupe by fact_type, keeping the highest-confidence entry.
  const bestByType = new Map();
  for (const f of accepted) {
    const cur = bestByType.get(f.fact_type);
    if (!cur || f.confidence > cur.confidence) bestByType.set(f.fact_type, f);
  }
  const finalFacts = [...bestByType.values()];

  // ── Persist: replace existing rows for each fact_type (idempotent re-run). ──
  const agentId = options.agent_id || 'mines-data-interpreter';
  const del = db.prepare('DELETE FROM mines_extracted_facts WHERE project_id = ? AND fact_type = ?');
  const ins = db.prepare(`
    INSERT INTO mines_extracted_facts
      (id, project_id, fact_type, value_text, value_json, confidence,
       source_document_id, source_chunk_id, source_quote, page_or_location,
       extracted_by_agent_id, created_at, updated_at)
    VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
  `);
  db.prepare('BEGIN').run();
  try {
    const now = nowIso();
    for (const f of finalFacts) {
      del.run(projectId, f.fact_type);
      ins.run(
        newId('mef'), projectId, f.fact_type, f.value_text, f.confidence,
        f.source_document_id, f.source_chunk_id, f.source_quote, f.page_or_location,
        agentId, now, now
      );
    }
    db.prepare('COMMIT').run();
  } catch (err) {
    try { db.prepare('ROLLBACK').run(); } catch {}
    return { status: 'error', error: 'persist_failed', reason: err?.message || String(err) };
  }

  try {
    appendComplianceAudit({
      user_id: options.acting_on_behalf_of_user_id || options.user_id || 'system',
      org_id: options.org_id || 'personal-org',
      action: 'mines.facts.extracted',
      status: 'success',
      summary: `LLM-extracted ${finalFacts.length} cited facts for ${project.name} from ${doc.file_name} (${rejected.length} rejected)`,
      action_target_id: projectId,
      action_target_type: 'mines_project',
      meta: {
        project_id: projectId, document_id: doc.id, model,
        facts_written: finalFacts.length, facts_rejected: rejected.length,
        chunks_considered: allChunks.length, chunks_in_window: window.length,
        fact_types: finalFacts.map((f) => f.fact_type),
      },
    });
  } catch { /* non-fatal */ }

  return {
    status: 'ok',
    document_id: doc.id,
    document_name: doc.file_name,
    model,
    chunks_considered: allChunks.length,
    chunks_in_window: window.length,
    facts_proposed: proposed.length,
    facts_written: finalFacts.length,
    facts_rejected: rejected.length,
    fact_types: finalFacts.map((f) => f.fact_type),
    rejected: rejected.slice(0, 20),
  };
}
