/**
 * Aggregate-level resolver for clarification responses.
 *
 *   Step 1 (deterministic, mandatory): sum_match math + date-range check
 *   Step 2 (LLM commentary, best-effort): per-file plain-English notes
 *
 * Step 1 is the source of truth. Step 2 enriches with qualitative commentary
 * but never overrides the verdict — if the LLM step fails, file_notes is empty
 * and the deterministic summary still flows through.
 */
import 'server-only';
// ============================================================================
// Helpers
// ============================================================================
const DEFAULT_TOLERANCE = 0.05;
/**
 * Document types accepted as evidence for a sum_match (receipt-style proof
 * of payment). Files outside this list are flagged as off-topic and excluded
 * from the sum. 'unknown' is included so the resolver doesn't punish files
 * the classifier wasn't confident about.
 */
const SUM_MATCH_ACCEPTED_TYPES = new Set([
    'invoice',
    'receipt',
    'tax_invoice',
    'bill',
    'unknown',
]);
function is_acceptable_for_sum_match(document_type) {
    if (!document_type)
        return true; // missing classification — give benefit of doubt
    return SUM_MATCH_ACCEPTED_TYPES.has(document_type);
}
/**
 * Phrases that, when present in `document_nature`, guarantee the file is NOT
 * a receipt for repairs/maintenance even if `document_type` came back as a
 * generic 'invoice'. This is a deterministic safety net — the LLM relevance
 * check should also catch these, but we don't depend on its compliance.
 *
 * Match is case-insensitive substring against document_nature.
 */
const SUM_MATCH_NATURE_DENYLIST = [
    { phrase: 'settlement adjustment', label: 'settlement adjustment statement' },
    { phrase: 'purchaser settlement', label: 'purchaser settlement sheet' },
    { phrase: 'settlement statement', label: 'settlement statement' },
    { phrase: 'council rates', label: 'council rates notice' },
    { phrase: 'strata levy', label: 'strata levy invoice' },
    { phrase: 'body corporate', label: 'body corporate fee notice' },
    { phrase: 'insurance schedule', label: 'insurance policy schedule' },
    { phrase: 'certificate of currency', label: 'insurance certificate of currency' },
    { phrase: 'depreciation schedule', label: 'depreciation schedule' },
    { phrase: 'property valuation', label: 'property valuation' },
    { phrase: 'conveyancing', label: 'conveyancing service invoice' },
    { phrase: 'property purchase', label: 'property purchase document' },
    { phrase: 'property management fee', label: 'property management fee invoice' },
];
/**
 * Deterministic content check — returns the matched denylist label when the
 * file's document_nature contains any denied phrase, otherwise null. Lets us
 * flag a file as off-topic without waiting on (or trusting) the LLM verdict.
 */
function nature_denylist_match(document_nature) {
    if (!document_nature)
        return null;
    const haystack = document_nature.toLowerCase();
    for (const entry of SUM_MATCH_NATURE_DENYLIST) {
        if (haystack.includes(entry.phrase))
            return entry.label;
    }
    return null;
}
function format_doc_type(t) {
    if (!t)
        return 'document';
    return t.replace(/_/g, ' ');
}
function format_currency(amount, currency) {
    const sign = amount < 0 ? '-' : '';
    const abs = Math.abs(amount);
    const symbol = currency_symbol(currency);
    return `${sign}${symbol}${abs.toFixed(2)}`;
}
function currency_symbol(currency) {
    switch (currency.toUpperCase()) {
        case 'USD': return '$';
        case 'AUD': return 'A$';
        case 'NZD': return 'NZ$';
        case 'CAD': return 'C$';
        case 'GBP': return '£';
        case 'EUR': return '€';
        case 'JPY': return '¥';
        default: return `${currency} `;
    }
}
function date_in_range(date_str, range) {
    if (!date_str)
        return false;
    return date_str >= range.from && date_str <= range.to;
}
async function run_with_timeout(promise, ms) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error('commentary_timeout')), ms);
        promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
    });
}
function run_deterministic(input, tolerance, relevance) {
    if (input.expectation.kind !== 'sum_match') {
        // Future kinds not yet implemented — pass through with informational summary.
        return {
            passed: true,
            delta: 0,
            sum_actual: 0,
            sum_expected: 0,
            summary: `No deterministic resolver registered for kind '${input.expectation.kind}'.`,
            details: [],
            file_relevance: {},
        };
    }
    const sum_expected = input.expectation.amount;
    const currency = input.expectation.currency;
    const date_range = input.expectation.date_range;
    // Per-file relevance map — combines type filter + LLM relevance into one
    // record consumed by the caller (so each file's pill reflects the verdict).
    const file_relevance = {};
    // Filtering happens in two passes:
    //   1. Type filter (cheap, deterministic) — bank_statement etc. always
    //      excluded regardless of relevance.
    //   2. Relevance filter (LLM) — wrong-property invoices flagged as mismatch
    //      land here. Defaults to 'matches' when the LLM didn't speak.
    const relevant = [];
    const off_topic_type = [];
    const mismatched = [];
    const unclear = [];
    for (const file of input.response_files) {
        if (!is_acceptable_for_sum_match(file.document_type)) {
            off_topic_type.push({
                file_id: file.file_id,
                name: file.file_name ?? file.file_id,
                document_type: file.document_type,
            });
            file_relevance[file.file_id] = {
                verdict: 'off_topic',
                reason: `looks like a ${format_doc_type(file.document_type)}, not a receipt`,
            };
            continue;
        }
        // Content denylist — when document_type is a generic 'invoice' but the
        // document_nature reveals it's actually a settlement adjustment, council
        // rates notice, etc., reject deterministically. Catches the case where
        // the LLM relevance check is too lenient.
        const nature_match = nature_denylist_match(file.document_nature);
        if (nature_match) {
            off_topic_type.push({
                file_id: file.file_id,
                name: file.file_name ?? file.file_id,
                document_type: nature_match,
            });
            file_relevance[file.file_id] = {
                verdict: 'off_topic',
                reason: `looks like a ${nature_match}, not a receipt for the requested expense`,
            };
            continue;
        }
        const r = relevance[file.file_id];
        if (r?.verdict === 'mismatch') {
            mismatched.push({ file_id: file.file_id, name: file.file_name ?? file.file_id, reason: r.reason });
            file_relevance[file.file_id] = { verdict: 'mismatch', reason: r.reason };
            continue;
        }
        if (r?.verdict === 'unclear') {
            unclear.push({ file_id: file.file_id, name: file.file_name ?? file.file_id, reason: r.reason });
            file_relevance[file.file_id] = { verdict: 'unclear', reason: r.reason };
        }
        else {
            file_relevance[file.file_id] = {
                verdict: 'matches',
                reason: r?.reason ?? 'matches the expected expense',
            };
        }
        relevant.push(file);
    }
    let sum_actual = 0;
    const date_violations = [];
    const missing_totals = [];
    for (const file of relevant) {
        const fields = file.extracted_fields;
        if (!fields || typeof fields.total !== 'number') {
            missing_totals.push({ file_id: file.file_id, name: file.file_name ?? file.file_id });
            continue;
        }
        sum_actual += fields.total;
        if (date_range && fields.date && !date_in_range(fields.date, date_range)) {
            date_violations.push({ file_id: file.file_id, name: file.file_name ?? file.file_id, date: fields.date });
        }
    }
    const delta = +(sum_actual - sum_expected).toFixed(2);
    const within_tolerance = Math.abs(delta) <= tolerance;
    const passed = within_tolerance &&
        date_violations.length === 0 &&
        missing_totals.length === 0 &&
        off_topic_type.length === 0 &&
        mismatched.length === 0 &&
        unclear.length === 0;
    const expected_str = format_currency(sum_expected, currency);
    const actual_str = format_currency(sum_actual, currency);
    // Build structured bullet list. Sum status comes first, then each excluded
    // file gets its own bullet so the client can see exactly which file is the
    // problem.
    const details = [];
    if (passed) {
        details.push({
            kind: 'sum',
            severity: 'success',
            message: `Receipts add up to ${expected_str} — matches the expected total.`,
        });
    }
    else if (within_tolerance) {
        details.push({
            kind: 'sum',
            severity: 'success',
            message: `Sum matches ${expected_str}.`,
        });
    }
    else {
        const short = delta < 0;
        const gap = format_currency(Math.abs(delta), currency);
        const message = short
            ? `So far we've received receipts totalling ${actual_str}, but the expected total is ${expected_str}. Please provide additional receipt(s) covering the remaining ${gap}.`
            : `Receipts uploaded so far total ${actual_str}, which exceeds the expected ${expected_str} by ${gap}. Please confirm whether all of these belong to the requested expense.`;
        details.push({
            kind: 'sum',
            severity: 'warning',
            message,
        });
    }
    for (const o of off_topic_type) {
        details.push({
            kind: 'off_topic',
            severity: 'warning',
            message: `looks like a ${format_doc_type(o.document_type)}, not a receipt — not counted toward the total`,
            file_id: o.file_id,
            file_name: o.name,
        });
    }
    for (const m of mismatched) {
        details.push({
            kind: 'mismatch',
            severity: 'warning',
            message: m.reason,
            file_id: m.file_id,
            file_name: m.name,
        });
    }
    for (const u of unclear) {
        details.push({
            kind: 'unclear',
            severity: 'info',
            message: u.reason,
            file_id: u.file_id,
            file_name: u.name,
        });
    }
    for (const d of date_violations) {
        details.push({
            kind: 'date_violation',
            severity: 'warning',
            message: `dated ${d.date}, outside the expected range`,
            file_id: d.file_id,
            file_name: d.name,
        });
    }
    for (const m of missing_totals) {
        details.push({
            kind: 'missing_total',
            severity: 'info',
            message: `couldn't read a total from this file — please confirm it's the right one`,
            file_id: m.file_id,
            file_name: m.name,
        });
    }
    // Plain-string summary kept for fallback rendering / logs.
    const all_excluded = relevant.length === 0 && (off_topic_type.length > 0 || mismatched.length > 0);
    let summary;
    if (passed) {
        summary = `Receipts add up to ${expected_str} — matches the expected total.`;
    }
    else if (all_excluded) {
        summary = `None of the uploaded files look like receipts for the expected expense.`;
    }
    else if (!within_tolerance) {
        const short = delta < 0;
        const gap = format_currency(Math.abs(delta), currency);
        summary = short
            ? `Receipts so far total ${actual_str} of the expected ${expected_str} — short by ${gap}.`
            : `Receipts so far total ${actual_str}, which exceeds the expected ${expected_str} by ${gap}.`;
    }
    else if (date_violations.length > 0) {
        summary = `Sum matches ${expected_str} but some receipts are outside the expected date range.`;
    }
    else if (missing_totals.length > 0) {
        summary = `Could not read totals from one or more files.`;
    }
    else {
        summary = `Receipts add up to ${actual_str}, expected ${expected_str}.`;
    }
    return {
        passed,
        delta,
        sum_actual: +sum_actual.toFixed(2),
        sum_expected,
        summary,
        details,
        file_relevance,
    };
}
// ============================================================================
// Public entrypoint
// ============================================================================
/**
 * Run the two-step resolver.
 *
 *   Step 1 — LLM commentary + relevance (best-effort, optional)
 *   Step 2 — deterministic math, gated by relevance verdicts
 *
 * Commentary runs first so its per-file relevance can influence which files
 * count toward the sum. When the LLM is unavailable or fails, relevance is
 * empty and the deterministic step runs with type-filter only — preserving
 * the prior behaviour as a graceful fallback.
 */
export async function run_resolution(input, options = {}) {
    const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
    const commentary_timeout_ms = options.commentary_timeout_ms ?? 5000;
    const on_error = options.on_error ?? ((err, ctx) => console.error(`[resolution] ${ctx}:`, err));
    let file_notes = {};
    let relevance = {};
    if (options.commentary_fn) {
        // Pre-deterministic estimate so commentary can reason about the gap.
        // Excludes mismatch logic (relevance isn't known yet) — close enough.
        const preview = preview_sum(input);
        try {
            const commentary = await run_with_timeout(options.commentary_fn({
                expectation: input.expectation,
                files: input.response_files,
                verdict: {
                    sum_actual: preview.sum_actual,
                    sum_expected: preview.sum_expected,
                    delta: preview.delta,
                },
            }), commentary_timeout_ms);
            if (commentary) {
                file_notes = commentary.file_notes ?? {};
                relevance = commentary.relevance ?? {};
            }
        }
        catch (err) {
            on_error(err, 'commentary step failed (continuing with deterministic verdict)');
        }
    }
    const verdict = run_deterministic(input, tolerance, relevance);
    return {
        passed: verdict.passed,
        delta: verdict.delta,
        sum_actual: verdict.sum_actual,
        sum_expected: verdict.sum_expected,
        file_notes,
        file_relevance: verdict.file_relevance,
        summary: verdict.summary,
        details: verdict.details,
        ran_at: new Date().toISOString(),
    };
}
/**
 * Best-effort sum used by the commentary step. Excludes type-incompatible
 * files (bank statements etc.) but includes all others — relevance is decided
 * downstream of this number.
 */
function preview_sum(input) {
    const sum_expected = input.expectation.kind === 'sum_match' ? input.expectation.amount : 0;
    let sum_actual = 0;
    for (const file of input.response_files) {
        if (!is_acceptable_for_sum_match(file.document_type))
            continue;
        if (nature_denylist_match(file.document_nature))
            continue;
        const t = file.extracted_fields?.total;
        if (typeof t === 'number')
            sum_actual += t;
    }
    return {
        sum_actual: +sum_actual.toFixed(2),
        sum_expected,
        delta: +(sum_actual - sum_expected).toFixed(2),
    };
}
/**
 * Creates a POST route handler that wraps run_resolution. Mirrors the factory
 * pattern used by validation_handler / classification_handler / autofill_handler.
 */
export function create_resolution_route(options = {}) {
    const on_error = options.on_error ?? ((err, ctx) => console.error(`[resolution] ${ctx}:`, err));
    return async function POST(request) {
        try {
            const body = await request.json();
            if (!body || !body.expectation) {
                return Response.json({ success: false, error: 'expectation is required' }, { status: 400 });
            }
            if (!Array.isArray(body.response_files)) {
                return Response.json({ success: false, error: 'response_files must be an array' }, { status: 400 });
            }
            const check = await run_resolution({ expectation: body.expectation, response_files: body.response_files }, options);
            return Response.json({ success: true, check });
        }
        catch (err) {
            on_error(err, 'unhandled error');
            return Response.json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 });
        }
    };
}
/**
 * Build a commentary_fn that calls hazo_llm_api with the resolve_clarification_relevance
 * prompt, attaching every inspectable response file inline. Wire this into
 * `create_resolution_route({ commentary_fn: create_llm_commentary_fn(...) })`.
 *
 * The returned function is best-effort: any failure (LLM error, JSON parse
 * fail, missing prompt) returns `null` so the resolver falls back to the
 * deterministic-only verdict.
 */
export function create_llm_commentary_fn(options) {
    const on_error = options.on_error ?? ((err, ctx) => console.error(`[resolution-commentary] ${ctx}:`, err));
    // Lazy-loaded module reference + init guard, copied from classification_handler.
    let llm_api_module = null;
    let init_promise = null;
    async function get_llm_api() {
        if (!llm_api_module) {
            const module_id = 'hazo_llm_api/server';
            llm_api_module = await import(/* webpackIgnore: true */ module_id);
        }
        return llm_api_module;
    }
    async function ensure_initialized() {
        const api = await get_llm_api();
        if (!api.is_initialized()) {
            if (!init_promise) {
                const init_config = options.hazo_connect_adapter
                    ? { hazo_connect: options.hazo_connect_adapter }
                    : { sqlite_path: options.sqlite_path };
                init_promise = api.initialize_llm_api(init_config).then(() => { });
            }
            await init_promise;
        }
    }
    return async function commentary_fn(input) {
        try {
            await ensure_initialized();
            const api = await get_llm_api();
            // Fetch the prompt text from hazo_prompts.
            const connect = api.get_hazo_connect?.() ?? null;
            if (!connect) {
                on_error(new Error('hazo_connect not available'), 'prompt lookup');
                return null;
            }
            const lookup = await connect.get_by_area_key(options.prompt_area, options.prompt_key);
            const db_prompt = lookup.success ? lookup.data?.prompt_text_full : undefined;
            if (!db_prompt) {
                on_error(new Error(`prompt ${options.prompt_area}/${options.prompt_key} missing`), 'prompt lookup');
                return null;
            }
            // Render variables.
            const exp = input.expectation;
            const ctx = exp.kind === 'sum_match' ? exp.context : undefined;
            const description = exp.kind === 'sum_match' ? exp.description : undefined;
            const expected_amount = exp.kind === 'sum_match'
                ? format_currency(exp.amount, exp.currency)
                : 'n/a';
            const expected_date_range = exp.kind === 'sum_match' && exp.date_range
                ? `${exp.date_range.from} to ${exp.date_range.to}`
                : 'n/a';
            const files_summary = input.files
                .map(f => {
                const lines = [`file_id: ${f.file_id}`, `filename: ${f.file_name ?? '(unnamed)'}`];
                if (f.document_type)
                    lines.push(`classified as: ${f.document_type}`);
                if (f.document_nature)
                    lines.push(`document_nature: ${f.document_nature}`);
                const ef = f.extracted_fields;
                if (ef) {
                    const parts = [];
                    if (typeof ef.total === 'number')
                        parts.push(`total=${ef.total}`);
                    if (ef.date)
                        parts.push(`date=${ef.date}`);
                    if (ef.vendor)
                        parts.push(`vendor=${ef.vendor}`);
                    if (ef.property_address)
                        parts.push(`property_address=${ef.property_address}`);
                    if (parts.length > 0)
                        lines.push(`extracted: ${parts.join(', ')}`);
                }
                return lines.join('\n');
            })
                .join('\n\n');
            const prompt_text = substitute(db_prompt, {
                expectation_description: description ?? '(no description provided)',
                expected_amount,
                expected_date_range,
                property_address: ctx?.property_address ?? 'n/a',
                vendor_hint: ctx?.vendor_hint ?? 'n/a',
                expense_label: ctx?.expense_label ?? 'n/a',
                files_summary: files_summary || '(no files)',
            });
            // Attach file contents. The first inspectable file becomes the primary
            // document; the rest go in additional_documents. Files without b64 are
            // skipped — the prompt's files_summary still references them by id.
            const inspectable = input.files.filter(f => !!f.file_b64 && !!f.mime_type);
            if (inspectable.length === 0) {
                on_error(new Error('no inspectable files (b64 missing)'), 'attachment build');
                return null;
            }
            const [primary, ...rest] = inspectable;
            const response = await api.hazo_llm_document_text({
                prompt: prompt_text,
                document_b64: primary.file_b64,
                document_mime_type: primary.mime_type,
                ...(rest.length > 0
                    ? {
                        additional_documents: rest.map(f => ({
                            mime_type: f.mime_type,
                            data: f.file_b64,
                        })),
                    }
                    : {}),
            });
            if (!response.success || !response.text) {
                on_error(response.error ?? new Error('LLM call failed'), 'llm call');
                return null;
            }
            // Parse JSON, tolerating fenced code blocks the LLM sometimes adds.
            const parsed = parse_commentary_json(response.text);
            if (!parsed) {
                on_error(new Error('JSON parse failed'), 'response parsing');
                return null;
            }
            return parsed;
        }
        catch (err) {
            on_error(err, 'commentary_fn');
            return null;
        }
    };
}
function substitute(prompt, vars) {
    return prompt.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
}
function parse_commentary_json(text) {
    const cleaned = text.trim();
    const candidates = [cleaned];
    const fence = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
    if (fence)
        candidates.push(fence[1].trim());
    const obj = cleaned.match(/\{[\s\S]*\}/);
    if (obj)
        candidates.push(obj[0]);
    for (const candidate of candidates) {
        try {
            const parsed = JSON.parse(candidate);
            if (parsed && typeof parsed === 'object') {
                const file_notes = (parsed.file_notes && typeof parsed.file_notes === 'object')
                    ? parsed.file_notes
                    : {};
                const relevance = (parsed.relevance && typeof parsed.relevance === 'object')
                    ? sanitize_relevance(parsed.relevance)
                    : {};
                const overall_note = typeof parsed.overall_note === 'string' ? parsed.overall_note : undefined;
                return { file_notes, relevance, overall_note };
            }
        }
        catch {
            // Try the next candidate.
        }
    }
    return null;
}
function sanitize_relevance(raw) {
    const out = {};
    for (const [file_id, value] of Object.entries(raw)) {
        if (!value || typeof value !== 'object')
            continue;
        const v = value;
        const verdict = v.verdict;
        const reason = typeof v.reason === 'string' ? v.reason : '';
        if (verdict === 'matches' || verdict === 'mismatch' || verdict === 'unclear') {
            out[file_id] = { verdict, reason };
        }
    }
    return out;
}
//# sourceMappingURL=resolution_handler.js.map