/**
 * Server-side classification route handler factory.
 *
 * Creates a POST handler that classifies uploaded documents via LLM analysis,
 * returning document type, tags, confidence, and optional metadata.
 *
 * Usage:
 *   import { create_classification_route } from 'hazo_collab_forms/lib';
 *   export const POST = create_classification_route({ hazo_connect_adapter: getHazoConnect() });
 *
 * Uses standard Web API Request/Response for portability.
 * hazo_llm_api is dynamically imported so it's only required at runtime.
 */
import 'server-only';
import { get_config } from './config.js';
// ============================================================================
// Helpers
// ============================================================================
function is_image_mime(mime_type) {
    return mime_type.startsWith('image/');
}
function is_document_mime(mime_type) {
    return mime_type === 'application/pdf';
}
/** Render the document-types list as a multi-line string for prompt substitution. */
function render_document_types(list) {
    if (!list || list.length === 0) {
        return '(none — respond with "unknown" for document_type)';
    }
    return list.map(dt => {
        const label = dt.type_label ?? dt.type_id;
        const desc = dt.description ? ` — ${dt.description}` : '';
        const head = `- ${dt.type_id}: ${label}${desc}`;
        if (!dt.expected_fields || dt.expected_fields.length === 0)
            return head;
        const lines = dt.expected_fields.map(f => render_expected_field(f, 4)).join('\n');
        return `${head}\n  Expected fields (return as keys in extracted_fields):\n${lines}`;
    }).join('\n');
}
/** Render a single ExpectedField as one or more indented lines.
 *  `indent` is the leading-space count for the field's primary line. */
function render_expected_field(f, indent) {
    const pad = ' '.repeat(indent);
    const description = f.description ? ` — ${f.description}` : '';
    if (f.array_of && f.array_of.type === 'object') {
        const meta_pad = ' '.repeat(indent + 2);
        const shape_lines = f.array_of.shape
            .map(s => render_expected_field(s, indent + 4))
            .join('\n');
        return `${pad}${f.key} (array of objects)${description}\n${meta_pad}Each object has these keys:\n${shape_lines}`;
    }
    const type_suffix = f.type ? ` (${f.type})` : '';
    return `${pad}${f.key}${type_suffix}${description}`;
}
/** Build the `extracted_fields_instruction` prompt variable based on the supplied
 *  doctype list. Pure function, exported on `__test_only__` for unit testing. */
function build_extracted_fields_instruction(document_types) {
    const has_expected_fields = !!document_types?.some(dt => dt.expected_fields && dt.expected_fields.length > 0);
    const has_array_of = !!document_types?.some(dt => dt.expected_fields?.some(f => f.array_of && f.array_of.type === 'object'));
    const base_instruction = has_expected_fields
        ? 'Return the listed expected_fields for the chosen doctype above; you may also add additional salient keys.'
        : 'Return any fields you would consider salient for this document type as keys in extracted_fields.';
    const array_clause = has_array_of
        ? ' For fields marked "array of objects", return a JSON array where each element is an object with the listed keys. Return an empty array if none apply.'
        : '';
    return base_instruction + array_clause;
}
/** Render the allowed-tags list as a multi-line string for prompt substitution. */
function render_tag_list(list) {
    if (!list || list.length === 0) {
        return '(no allow-list — return an empty tags array)';
    }
    return list.map(t => {
        const label = t.tag_label ?? t.tag_id;
        const desc = t.description ? ` — ${t.description}` : '';
        return `- ${t.tag_id}: ${label}${desc}`;
    }).join('\n');
}
/** Substitute {{var}} placeholders. Unknown variables are left as-is. */
function substitute_variables(prompt, variables) {
    return prompt.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? `{{${key}}}`);
}
/**
 * Parse LLM response for classification results.
 *
 * The prompt asks for an array (one entry per file); for single-file
 * classification we take the first entry.
 */
function parse_classification_response(text) {
    const cleaned = text.trim();
    const candidates = [cleaned];
    const fence_match = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
    if (fence_match)
        candidates.push(fence_match[1].trim());
    const array_match = cleaned.match(/\[[\s\S]*\]/);
    if (array_match)
        candidates.push(array_match[0]);
    const object_match = cleaned.match(/\{[\s\S]*\}/);
    if (object_match)
        candidates.push(object_match[0]);
    for (const candidate of candidates) {
        try {
            const parsed = JSON.parse(candidate);
            const item = pick_first_entry(parsed);
            if (item)
                return extract_classification_fields(item);
        }
        catch {
            // Try next candidate
        }
    }
    return {
        document_type: 'unknown',
        tags: [],
        tag_reasons: [],
        confidence: 0,
        document_nature: cleaned.slice(0, 200),
    };
}
function pick_first_entry(parsed) {
    if (Array.isArray(parsed)) {
        return parsed[0] ?? null;
    }
    if (parsed && typeof parsed === 'object') {
        return parsed;
    }
    return null;
}
function extract_classification_fields(parsed) {
    const raw_tags = Array.isArray(parsed.tags) ? parsed.tags : [];
    const tags = [];
    const tag_reasons = [];
    for (const t of raw_tags) {
        if (typeof t === 'string') {
            tags.push(t);
        }
        else if (t && typeof t === 'object') {
            const name = String(t.name ?? '').trim();
            if (!name)
                continue;
            tags.push(name);
            const reason = t.reason;
            if (typeof reason === 'string' && reason.trim()) {
                tag_reasons.push({ name, reason: reason.trim() });
            }
        }
    }
    // document_type may come through as a string or a single-element array.
    let document_type = 'unknown';
    const raw_dt = parsed.document_type;
    if (typeof raw_dt === 'string' && raw_dt.trim()) {
        document_type = raw_dt.trim();
    }
    else if (Array.isArray(raw_dt) && raw_dt.length > 0 && typeof raw_dt[0] === 'string') {
        document_type = raw_dt[0].trim() || 'unknown';
    }
    // confidence as decimal; if the LLM slips and returns a percent (>1.5), normalise.
    let confidence = 0;
    if (typeof parsed.confidence === 'number' && Number.isFinite(parsed.confidence)) {
        confidence = parsed.confidence > 1.5 ? parsed.confidence / 100 : parsed.confidence;
        if (confidence < 0)
            confidence = 0;
        if (confidence > 1)
            confidence = 1;
    }
    const document_date = typeof parsed.document_date === 'string' && parsed.document_date.trim()
        ? parsed.document_date.trim()
        : undefined;
    const document_nature = typeof parsed.document_nature === 'string' && parsed.document_nature.trim()
        ? parsed.document_nature.trim()
        : undefined;
    // Structured fields — preserve named-four normalization, then merge in any
    // additional keys the LLM returned. Drops null/undefined/empty-string.
    // Top-level non-primitive values are JSON.stringify'd.
    let extracted_fields;
    const ef_source = (parsed.extracted_fields && typeof parsed.extracted_fields === 'object' && !Array.isArray(parsed.extracted_fields))
        ? parsed.extracted_fields
        : parsed;
    const total_raw = ef_source.total ?? ef_source.amount ?? ef_source.invoice_total;
    const date_raw = ef_source.invoice_date ?? ef_source.receipt_date ?? (parsed.extracted_fields ? ef_source.date : undefined);
    const vendor_raw = ef_source.vendor ?? ef_source.supplier ?? ef_source.payee;
    const address_raw = ef_source.property_address ?? ef_source.address ?? ef_source.site_address;
    let total;
    if (typeof total_raw === 'number' && Number.isFinite(total_raw)) {
        total = total_raw;
    }
    else if (typeof total_raw === 'string' && total_raw.trim()) {
        const n = parseFloat(total_raw.replace(/[^\d.\-]/g, ''));
        if (Number.isFinite(n))
            total = n;
    }
    const date_str = typeof date_raw === 'string' && date_raw.trim() ? date_raw.trim() : undefined;
    const vendor_str = typeof vendor_raw === 'string' && vendor_raw.trim() ? vendor_raw.trim() : undefined;
    const address_str = typeof address_raw === 'string' && address_raw.trim() ? address_raw.trim() : undefined;
    // Pass through any additional keys the LLM nested under `extracted_fields`.
    // Intentionally NOT scanning the root of `parsed`: the named-four fallback
    // above accepts root-level legacy keys for back-compat, but any new
    // doctype-specific extras MUST be nested under `extracted_fields` (the
    // {{extracted_fields_instruction}} prompt variable directs the LLM to do
    // exactly that). Root-level extras are silently dropped.
    const extras = {};
    if (parsed.extracted_fields && typeof parsed.extracted_fields === 'object' && !Array.isArray(parsed.extracted_fields)) {
        // Skip the canonical names + their root-level synonyms; these are already
        // consumed by the named-four extraction above.
        const named = new Set(['total', 'date', 'vendor', 'property_address',
            'amount', 'invoice_total', 'invoice_date', 'receipt_date', 'supplier', 'payee', 'address', 'site_address']);
        for (const [k, v] of Object.entries(parsed.extracted_fields)) {
            if (named.has(k))
                continue;
            if (v === null || v === undefined)
                continue;
            if (typeof v === 'string' && v.trim() === '')
                continue;
            if (Array.isArray(v)) {
                // Pass arrays through as-is so the renderer can table-render
                // Array<object> (declared `array_of` doctype shape OR LLM extras)
                // and bullet-list Array<scalar>. Stringifying here would force the
                // client into the JSON-string fallback path.
                extras[k] = v;
            }
            else if (typeof v === 'object') {
                // Plain (non-array) objects still get stringified — the renderer
                // has no first-class display for nested object values today, so a
                // readable JSON snippet is better than nothing.
                try {
                    extras[k] = JSON.stringify(v);
                }
                catch {
                    extras[k] = String(v);
                }
            }
            else {
                extras[k] = v;
            }
        }
    }
    if (total !== undefined || date_str || vendor_str || address_str || Object.keys(extras).length > 0) {
        extracted_fields = {
            ...(total !== undefined ? { total } : {}),
            ...(date_str ? { date: date_str } : {}),
            ...(vendor_str ? { vendor: vendor_str } : {}),
            ...(address_str ? { property_address: address_str } : {}),
            ...extras,
        };
    }
    return { document_type, tags, tag_reasons, confidence, document_date, document_nature, extracted_fields };
}
// ============================================================================
// Route Handler Factory
// ============================================================================
/**
 * Creates a POST route handler for document classification.
 *
 * The handler:
 * 1. Validates the request payload
 * 2. Sends the document to an LLM for classification
 * 3. Parses the response into structured classification data
 * 4. Returns the classification result
 */
export function create_classification_route(options) {
    const sqlite_path = options?.sqlite_path ?? get_config('hazo_connect', 'prompt_sqlite_path') ?? 'data/prompt_library.sqlite';
    const hazo_connect_adapter = options?.hazo_connect_adapter;
    const on_error = options?.on_error ?? ((err, ctx) => console.error(`[classification] ${ctx}:`, err));
    const prompt_area = options?.prompt_area ?? 'document';
    const prompt_key = options?.prompt_key ?? 'classification';
    // Cache the dynamic import
    let llm_api_module = null;
    let init_promise = null;
    async function get_llm_api() {
        if (!llm_api_module) {
            try {
                const module_id = 'hazo_llm_api/server';
                llm_api_module = await import(/* webpackIgnore: true */ module_id);
            }
            catch {
                throw new Error('hazo_llm_api is required for classification. Install it: npm install hazo_llm_api');
            }
        }
        return llm_api_module;
    }
    async function ensure_initialized() {
        const api = await get_llm_api();
        if (!api.is_initialized()) {
            if (!init_promise) {
                const init_config = hazo_connect_adapter
                    ? { hazo_connect: hazo_connect_adapter }
                    : { sqlite_path };
                init_promise = api.initialize_llm_api(init_config).then(() => { });
            }
            await init_promise;
        }
    }
    return async function POST(request) {
        try {
            const body = await request.json();
            const { file_b64, file_name, mime_type, document_types, available_tags, tag_list, client_text } = body;
            // Validate required fields
            if (!file_b64 || !file_name || !mime_type) {
                return Response.json({ success: false, error: 'file_b64, file_name, and mime_type are required' }, { status: 400 });
            }
            // Initialize LLM API
            await ensure_initialized();
            const api = await get_llm_api();
            // Load prompt from hazo_prompts. No hardcoded fallback — error loudly so
            // missing prompts get spotted in dev rather than silently using stale code.
            const connect = api.get_hazo_connect?.() ?? null;
            if (!connect) {
                return Response.json({ success: false, error: 'hazo_llm_api hazo_connect is not available — cannot load prompts from hazo_prompts' }, { status: 500 });
            }
            const lookup = await connect.get_by_area_key(prompt_area, prompt_key);
            const db_prompt = lookup.success ? lookup.data?.prompt_text_full : undefined;
            if (!db_prompt) {
                return Response.json({ success: false, error: `Prompt ${prompt_area}/${prompt_key} not found in hazo_prompts. Seed it before calling this route.` }, { status: 500 });
            }
            // The prompt uses a mix of {{document_types}} and {{document_type}} to
            // mean the doc-types list; map both to the same rendered list.
            const doc_types_text = render_document_types(document_types);
            const tags_text = tag_list ?? render_tag_list(available_tags);
            const extracted_fields_instruction = build_extracted_fields_instruction(document_types);
            const prompt_text = substitute_variables(db_prompt, {
                document_types: doc_types_text,
                document_type: doc_types_text,
                tag_list: tags_text,
                client_text: client_text ?? '(none)',
                // Single-file flow: synthesise a minimal file_list. The LLM still
                // inspects the file contents; file_id is just a correlation key.
                file_list: `- ${file_name}`,
                extracted_fields_instruction,
            });
            // Call LLM based on mime type
            let llm_response;
            if (is_image_mime(mime_type)) {
                llm_response = await api.hazo_llm_image_text({
                    prompt: prompt_text,
                    image_b64: file_b64,
                    image_mime_type: mime_type,
                });
            }
            else if (is_document_mime(mime_type)) {
                llm_response = await api.hazo_llm_document_text({
                    prompt: prompt_text,
                    document_b64: file_b64,
                    document_mime_type: mime_type,
                });
            }
            else {
                // Fallback: treat as image
                llm_response = await api.hazo_llm_image_text({
                    prompt: prompt_text,
                    image_b64: file_b64,
                    image_mime_type: mime_type,
                });
            }
            if (!llm_response.success || !llm_response.text) {
                return Response.json({ success: false, error: llm_response.error ?? 'LLM call failed' }, { status: 500 });
            }
            // Parse classification result
            const result = parse_classification_response(llm_response.text);
            // Enforce the document_types allow-list. The prompt instructs the LLM to
            // pick from the list, but LLMs occasionally invent their own label (e.g.
            // "property_statement" when only "rental_statement" is configured). When
            // that happens, fall back to 'unknown' so downstream validation doesn't
            // key off a type the rest of the system has never heard of.
            let document_type = result.document_type;
            if (document_types && document_types.length > 0 && document_type !== 'unknown') {
                const allowed = new Set(document_types.map(d => d.type_id));
                if (!allowed.has(document_type)) {
                    on_error(new Error(`LLM returned document_type "${document_type}" which is not in the configured allow-list (${[...allowed].join(', ')}). Falling back to "unknown".`), 'document_type not in allow-list');
                    document_type = 'unknown';
                }
            }
            // Defensive warning — if the LLM came back "unknown" AND we never gave
            // it an allow-list to choose from, that's almost certainly the root
            // cause. Surface a clear hint so future debugging doesn't repeat the
            // hazo_app_config-empty rabbit hole.
            if (document_type === 'unknown' &&
                result.confidence === 0 &&
                (!document_types || document_types.length === 0)) {
                const msg = `[classification_handler] document_type='unknown' with confidence=0 — and no document_types allow-list was passed. ` +
                    `Likely cause: the consumer didn't provide \`available_document_types\` (or the underlying config table is empty). ` +
                    `Populate the doc-types config (e.g. via the doc-type-editor page or a seed script) so the LLM has a list to choose from.`;
                console.warn(msg);
                on_error(new Error(msg), 'empty document_types allow-list');
            }
            // Enforce the tags allow-list when provided.
            let tags = result.tags;
            let tag_reasons = result.tag_reasons;
            if (available_tags && available_tags.length > 0) {
                const allowed_tags = new Set(available_tags.map(t => t.tag_id));
                tags = tags.filter(t => allowed_tags.has(t));
                tag_reasons = tag_reasons.filter(r => allowed_tags.has(r.name));
            }
            const response = {
                success: true,
                document_type,
                tags,
                tag_reasons,
                confidence: result.confidence,
                document_date: result.document_date,
                document_nature: result.document_nature,
                ...(result.extracted_fields ? { extracted_fields: result.extracted_fields } : {}),
            };
            // Debug: when extraction came back empty for an invoice/receipt-type doc,
            // include the raw LLM text on the response so the consumer can inspect it
            // in the Debug JSON dialog. Stripped on extraction success to keep
            // payloads small.
            if (!result.extracted_fields && (document_type === 'invoice' || document_type === 'receipt' || document_type === 'tax_invoice')) {
                response._debug_llm_text = llm_response.text;
            }
            return Response.json(response);
        }
        catch (error) {
            on_error(error, 'unhandled error');
            return Response.json({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
        }
    };
}
/** Internal API for tests. Not part of the public surface. */
export const __test_only__ = {
    parse_classification_response,
    render_document_types,
    substitute_variables,
    build_extracted_fields_instruction,
};
//# sourceMappingURL=classification_handler.js.map