"""Storage adapter — write leaudit results into leaudit_platform PostgreSQL via SQLAlchemy.

Converts leaudit's OcrResult, ExtractionBundle, EvaluationResult
into leaudit_* table format and writes via SQLAlchemy async session.
"""

from __future__ import annotations

import json
import logging
import re
from typing import Any

from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from sqlalchemy import text

from leaudit.dsl.schema import RulesFile
from leaudit.engine.models import EvaluationResult, RuleResult
from leaudit.extraction.bundle import ExtractionBundle
from leaudit.extraction.models import FieldValue
from leaudit.ocr.models import OcrResult
from leaudit.rescue.models import RescueTask

log = logging.getLogger(__name__)


class StorageAdapter:
    """Write leaudit pipeline results to leaudit_platform database."""

    async def _ensure_run_id(self, document_id: int, run_id: int | None) -> int | None:
        """Return explicit ``run_id`` when given, otherwise fall back to latest run.

        The fallback path exists only for the legacy hand-written pipeline.
        Native ``AuditCtx`` integration should always pass ``run_id`` explicitly.
        """
        if run_id is not None:
            return run_id

        async with GetAsyncSession() as session:
            result = await session.execute(
                text("SELECT id FROM leaudit_audit_runs WHERE document_id = :did ORDER BY id DESC LIMIT 1"),
                {"did": document_id},
            )
            row = result.fetchone()
            return row[0] if row else None

    # ---- Document status ----

    async def update_document_status(self, document_id: int, status: str) -> None:
        """Update the document's processing_status."""
        async with GetAsyncSession() as session:
            await session.execute(
                text("UPDATE leaudit_documents SET processing_status = :s, updated_at = now() WHERE id = :did"),
                {"s": status, "did": document_id},
            )
            await session.commit()
        log.debug("[%d] Status updated: %s", document_id, status)

    # ---- Document number (案件编号) ----

    async def update_document_number(self, document_id: int, document_number: str) -> None:
        """Update the document's case number."""
        async with GetAsyncSession() as session:
            await session.execute(
                text("UPDATE leaudit_documents SET document_number = :dn, updated_at = now() WHERE id = :did"),
                {"dn": document_number, "did": document_id},
            )
            await session.commit()
        log.info("[%d] document_number updated: %s", document_id, document_number)

    # ---- OCR result (stored as leaudit_artifact) ----

    async def save_ocr_result(
        self,
        document_id: int,
        ocr_result: OcrResult,
        *,
        run_id: int | None = None,
    ) -> None:
        """Save OCR result as a leaudit_artifact record."""
        ocr_dict = _ocr_to_dict(ocr_result)
        full_text = ocr_result.full_text or "\n".join(p.text for p in ocr_result.pages)
        resolved_run_id = await self._ensure_run_id(document_id, run_id)

        async with GetAsyncSession() as session:
            await session.execute(
                text("""INSERT INTO leaudit_artifacts (run_id, document_id, artifact_type, artifact_role,
                    file_name, file_ext, mime_type, file_size, is_persisted, retention_policy)
                    VALUES (:rid, :did, 'ocr_json', 'output', 'ocr_result.json', 'json', 'application/json',
                    :fs, false, 'run_life')"""),
                {"rid": resolved_run_id, "did": document_id, "fs": len(full_text)},
            )
            await session.commit()
        log.info("[%d] OCR result saved (%d pages)", document_id, len(ocr_result.pages))

    # ---- Extraction result ----

    async def save_extraction_result(
        self,
        document_id: int,
        bundle: ExtractionBundle,
        ocr_result: OcrResult | None = None,
        *,
        run_id: int | None = None,
    ) -> None:
        """Save extraction result to leaudit_field_results table."""
        inferred_positions = _build_inferred_field_positions(bundle, ocr_result)
        extracted = _bundle_to_extracted(bundle, inferred_positions=inferred_positions)
        resolved_run_id = await self._ensure_run_id(document_id, run_id)

        async with GetAsyncSession() as session:
            for name, fv in bundle.fields.items():
                field_data = extracted.get("fields", {}).get(name, {})
                raw_value = fv.raw_value if isinstance(fv, FieldValue) else None
                meta_json = {
                    "position": (_field_value_position_payload(fv) if isinstance(fv, FieldValue) else None)
                    or inferred_positions.get(name),
                    "reasons": list(fv.reasons or []),
                    "type_name": fv.type_name,
                } if isinstance(fv, FieldValue) else None
                await session.execute(
                    text("""INSERT INTO leaudit_field_results (
                        run_id, document_id, field_name, field_type, value_text,
                        raw_value_json, confidence, logprob_score, grounding_score,
                        grounding_method, rule_score, hard_failed, fallback_value, meta_json
                    ) VALUES (
                        :rid, :did, :fn, :ft, :vt,
                        CAST(:rv AS JSONB), :cf, :lp, :gs,
                        :gm, :rs, :hf, :fv, CAST(:mj AS JSONB)
                    )
                        ON CONFLICT DO NOTHING"""),
                    {
                        "rid": resolved_run_id, "did": document_id,
                        "fn": name,
                        "ft": fv.type_name if isinstance(fv, FieldValue) else None,
                        "vt": str(field_data.get("value", "")),
                        "rv": json.dumps(raw_value, ensure_ascii=False) if raw_value is not None else None,
                        "cf": float(field_data.get("confidence", 0)),
                        "lp": float(fv.logprob or 0) if isinstance(fv, FieldValue) else None,
                        "gs": float(fv.grounding or 0) if isinstance(fv, FieldValue) else None,
                        "gm": fv.grounding_method if isinstance(fv, FieldValue) else None,
                        "rs": float(fv.rule_score or 0) if isinstance(fv, FieldValue) else None,
                        "hf": bool(fv.hard_failed) if isinstance(fv, FieldValue) else None,
                        "fv": fv.fallback_value if isinstance(fv, FieldValue) else None,
                        "mj": json.dumps(meta_json, ensure_ascii=False) if meta_json else None,
                    },
                )
            await session.commit()
        log.info("[%d] Extraction result saved (%d fields)", document_id, len(bundle.fields))

    # ---- Evaluation results ----

    async def save_evaluation_results(
        self,
        document_id: int,
        rules_file: RulesFile,
        evaluation: EvaluationResult,
        bundle: ExtractionBundle,
        ocr_result: OcrResult | None = None,
        *,
        run_id: int | None = None,
        rule_version_id: int | None = None,
    ) -> None:
        """Save evaluation results to leaudit_rule_results table.

        One row per rule. Deletes existing results for the document first,
        then inserts fresh rows.
        """
        resolved_run_id = await self._ensure_run_id(document_id, run_id)
        inferred_positions = _build_inferred_field_positions(bundle, ocr_result)
        async with GetAsyncSession() as session:
            # Delete existing results for this document+run
            await session.execute(
                text("DELETE FROM leaudit_rule_results WHERE document_id = :did AND run_id = :rid"),
                {"did": document_id, "rid": resolved_run_id},
            )

            # Build rule_id → rule metadata lookup
            rule_meta = {}
            for rule in rules_file.flat_rules:
                rule_meta[rule.rule_id] = rule

            # Insert one row per rule result
            for rule_result in evaluation.rules:
                rule = rule_meta.get(rule_result.rule_id)
                row = _rule_result_to_row(
                    document_id,
                    resolved_run_id,
                    rule_result,
                    rule,
                    bundle,
                    inferred_positions=inferred_positions,
                )
                if rule_version_id is not None:
                    row["rule_version_id"] = rule_version_id
                json_columns = {"stages", "extracted_fields", "field_positions", "remediation", "rule_meta"}
                serialized_row = {
                    key: (json.dumps(value, ensure_ascii=False) if key in json_columns and value is not None else value)
                    for key, value in row.items()
                }
                columns = ", ".join(serialized_row.keys())
                placeholders = ", ".join(
                    f"CAST(:{k} AS JSONB)" if k in json_columns else f":{k}"
                    for k in serialized_row
                )
                await session.execute(
                    text(f"INSERT INTO leaudit_rule_results ({columns}) VALUES ({placeholders})"),
                    serialized_row,
                )

            # Update audit_runs summary (scores only — terminal state set by finalize_run)
            await session.execute(
                text("""UPDATE leaudit_audit_runs SET
                    total_score = :ts, passed_count = :pc, failed_count = :fc,
                    skipped_count = :sc, updated_at = now()
                    WHERE id = :rid"""),
                {
                    "ts": evaluation.total_score,
                    "pc": evaluation.passed_count,
                    "fc": evaluation.failed_count,
                    "sc": evaluation.skipped_count,
                    "rid": resolved_run_id,
                },
            )
            await session.commit()

        log.info(
            "[%d] Evaluation results saved: %d passed, %d failed, %d skipped",
            document_id,
            evaluation.passed_count,
            evaluation.failed_count,
            evaluation.skipped_count,
        )

    async def save_run_metrics(
        self,
        document_id: int,
        *,
        run_id: int | None,
        timing: dict[str, float] | None = None,
        page_count: int | None = None,
        sub_document_count: int | None = None,
        field_count: int | None = None,
        rule_count: int | None = None,
        rescue_rule_count: int | None = None,
        artifact_count: int | None = None,
    ) -> None:
        """保存运行指标。"""
        resolved_run_id = await self._ensure_run_id(document_id, run_id)
        metric = dict(timing or {})

        async with GetAsyncSession() as session:
            await session.execute(
                text("DELETE FROM leaudit_run_metrics WHERE run_id = :rid"),
                {"rid": resolved_run_id},
            )
            await session.execute(
                text(
                    """
                    INSERT INTO leaudit_run_metrics (
                        run_id,
                        ocr_seconds,
                        normalize_seconds,
                        extract_seconds,
                        evaluate_seconds,
                        rescue_seconds,
                        total_seconds,
                        page_count,
                        sub_document_count,
                        field_count,
                        rule_count,
                        llm_call_count,
                        vlm_call_count,
                        rescue_rule_count,
                        artifact_count
                    ) VALUES (
                        :run_id,
                        :ocr_seconds,
                        :normalize_seconds,
                        :extract_seconds,
                        :evaluate_seconds,
                        :rescue_seconds,
                        :total_seconds,
                        :page_count,
                        :sub_document_count,
                        :field_count,
                        :rule_count,
                        :llm_call_count,
                        :vlm_call_count,
                        :rescue_rule_count,
                        :artifact_count
                    )
                    """
                ),
                {
                    "run_id": resolved_run_id,
                    "ocr_seconds": metric.get("ocr"),
                    "normalize_seconds": metric.get("normalize"),
                    "extract_seconds": metric.get("extraction", metric.get("extract")),
                    "evaluate_seconds": metric.get("evaluation", metric.get("evaluate")),
                    "rescue_seconds": metric.get("rescue"),
                    "total_seconds": metric.get("total"),
                    "page_count": page_count,
                    "sub_document_count": sub_document_count,
                    "field_count": field_count,
                    "rule_count": rule_count,
                    "llm_call_count": None,
                    "vlm_call_count": None,
                    "rescue_rule_count": rescue_rule_count,
                    "artifact_count": artifact_count,
                },
            )
            await session.commit()

    async def save_run_errors(
        self,
        document_id: int,
        *,
        run_id: int | None,
        stage: str,
        messages: list[str],
        level: str = "error",
        error_code: str | None = None,
        detail_json: dict[str, Any] | None = None,
    ) -> None:
        """保存运行错误。"""
        if not messages:
            return

        resolved_run_id = await self._ensure_run_id(document_id, run_id)
        async with GetAsyncSession() as session:
            for message in messages:
                await session.execute(
                    text(
                        """
                        INSERT INTO leaudit_run_errors (
                            run_id,
                            document_id,
                            stage,
                            level,
                            error_code,
                            message,
                            detail_json
                        ) VALUES (
                            :run_id,
                            :document_id,
                            :stage,
                            :level,
                            :error_code,
                            :message,
                            :detail_json
                        )
                        """
                    ),
                    {
                        "run_id": resolved_run_id,
                        "document_id": document_id,
                        "stage": stage,
                        "level": level,
                        "error_code": error_code,
                        "message": message,
                        "detail_json": json.dumps(detail_json, ensure_ascii=False)
                        if detail_json is not None
                        else None,
                    },
                )
            await session.commit()

    async def save_rescue_outcomes(
        self,
        document_id: int,
        *,
        run_id: int | None,
        tasks: tuple[RescueTask, ...] | list[RescueTask],
    ) -> None:
        """保存补救结果。"""
        if not tasks:
            return

        resolved_run_id = await self._ensure_run_id(document_id, run_id)
        async with GetAsyncSession() as session:
            await session.execute(
                text("DELETE FROM leaudit_rescue_outcomes WHERE run_id = :rid"),
                {"rid": resolved_run_id},
            )
            for task in tasks:
                final_status = "review" if task.requires_human_review else ("pass" if task.final_status == "pass" else "fail")
                await session.execute(
                    text(
                        """
                        INSERT INTO leaudit_rescue_outcomes (
                            run_id,
                            document_id,
                            rule_id,
                            status,
                            diagnosis,
                            diagnosis_confidence,
                            final_status,
                            failure_reason,
                            llm_calls,
                            vlm_calls,
                            duration_ms,
                            requires_human_review,
                            payload,
                            created_at,
                            updated_at
                        ) VALUES (
                            :run_id,
                            :document_id,
                            :rule_id,
                            :status,
                            :diagnosis,
                            :diagnosis_confidence,
                            :final_status,
                            :failure_reason,
                            :llm_calls,
                            :vlm_calls,
                            :duration_ms,
                            :requires_human_review,
                            CAST(:payload AS JSONB),
                            :created_at,
                            :updated_at
                        )
                        """
                    ),
                    {
                        "run_id": resolved_run_id,
                        "document_id": document_id,
                        "rule_id": task.rule_id,
                        "status": task.status.value,
                        "diagnosis": task.diagnosis.value if task.diagnosis else None,
                        "diagnosis_confidence": task.diagnosis_confidence,
                        "final_status": final_status,
                        "failure_reason": task.failure_reason,
                        "llm_calls": task.llm_calls,
                        "vlm_calls": task.vlm_calls,
                        "duration_ms": task.duration_ms,
                        "requires_human_review": task.requires_human_review,
                        "payload": json.dumps(task.model_dump(mode="json"), ensure_ascii=False),
                        "created_at": task.created_at,
                        "updated_at": task.updated_at,
                    },
                )
            await session.commit()

    async def finalize_run(
        self,
        document_id: int,
        *,
        run_id: int | None,
        result_status: str,
        rescue_applied: bool,
        phase: str | None = None,
        finished: bool = True,
    ) -> None:
        """更新运行主表的最终摘要状态。"""
        resolved_run_id = await self._ensure_run_id(document_id, run_id)
        async with GetAsyncSession() as session:
            await session.execute(
                text(
                    """
                    UPDATE leaudit_audit_runs
                    SET
                        phase = COALESCE(:phase, phase),
                        rescue_applied = :rescue_applied,
                        result_status = :result_status,
                        finished_at = CASE WHEN :finished THEN now() ELSE finished_at END,
                        updated_at = now()
                    WHERE id = :rid
                    """
                ),
                {
                    "phase": phase,
                    "rescue_applied": rescue_applied,
                    "result_status": result_status,
                    "finished": finished,
                    "rid": resolved_run_id,
                },
            )
            await session.commit()

    async def fail_run(
        self,
        document_id: int,
        *,
        run_id: int | None,
        phase: str | None,
        message: str,
        error_code: str = "AUDIT_RUN_FAILED",
        detail_json: dict[str, Any] | None = None,
    ) -> None:
        """记录运行失败并更新主表。"""
        resolved_run_id = await self._ensure_run_id(document_id, run_id)
        await self.save_run_errors(
            document_id,
            run_id=resolved_run_id,
            stage=phase or "persist",
            messages=[message],
            level="fatal",
            error_code=error_code,
            detail_json=detail_json,
        )
        async with GetAsyncSession() as session:
            await session.execute(
                text(
                    """
                    UPDATE leaudit_audit_runs
                    SET
                        status = 'failed',
                        phase = COALESCE(:phase, phase),
                        result_status = 'error',
                        finished_at = now(),
                        updated_at = now()
                    WHERE id = :rid
                    """
                ),
                {
                    "phase": phase,
                    "rid": resolved_run_id,
                },
            )
            await session.commit()


# ---- Serialization helpers ----


def _ocr_to_dict(ocr: OcrResult) -> dict[str, Any]:
    """Convert OcrResult to a JSON-safe dict for PostgREST storage."""
    result: dict[str, Any] = {
        "numPages": len(ocr.pages),
        "full_text": ocr.full_text,
        "pages": [],
    }

    for page in ocr.pages:
        page_dict: dict[str, Any] = {
            "page_num": page.page_num,
            "text": page.text,
            "page_box": page.page_box,
        }
        if page.chunks:
            page_dict["chunks"] = [
                (
                    {"bbox": c["bbox"], "content": c["content"], "label": c.get("label")}
                    if isinstance(c, dict) and "bbox" in c and "content" in c
                    else {
                        "bbox": c.bbox if hasattr(c, "bbox") else None,
                        "content": c.content if hasattr(c, "content") else str(c),
                        "label": c.label if hasattr(c, "label") else None,
                    }
                )
                for c in page.chunks
            ]
        if page.bboxes:
            page_dict["bboxes"] = page.bboxes
        result["pages"].append(page_dict)

    if ocr.visual_manifest:
        result["visual_manifest"] = ocr.visual_manifest.model_dump(mode="json")

    if ocr.images:
        result["images"] = ocr.images

    return result


def _bundle_to_extracted(
    bundle: ExtractionBundle,
    *,
    inferred_positions: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Convert ExtractionBundle to docauditai's extracted_results format."""
    inferred_positions = inferred_positions or {}
    fields: dict[str, Any] = {}
    for name, fv in bundle.fields.items():
        if isinstance(fv, FieldValue):
            field_data = {
                "value": fv.value,
                "confidence": float(fv.confidence) if fv.confidence else 0.0,
            }
            position_payload = _field_value_position_payload(fv) or inferred_positions.get(name)
            if position_payload is not None:
                field_data["position"] = position_payload
            fields[name] = field_data
        else:
            fields[name] = {"value": fv}

    multi_entity: dict[str, Any] = {}
    for name, rows in bundle.multi_entity.items():
        multi_entity[name] = [
            {
                k: (v.value if isinstance(v, FieldValue) else v)
                for k, v in row.items()
            }
            if isinstance(row, dict) else {"value": row}
            for row in rows
        ]

    return {
        "fields": fields,
        "multi_entity": multi_entity,
        "derived": dict(bundle.derived) if bundle.derived else {},
        "is_case_file": bundle.is_case_file,
    }


def _extract_relevant_fields(rule: Any, bundle: ExtractionBundle) -> dict[str, Any]:
    """Extract field values referenced by a rule's stages."""
    relevant: dict[str, Any] = {}

    if not rule or not hasattr(rule, "stages") or not rule.stages:
        return relevant

    for stage in rule.stages:
        stage_data = stage.model_dump(exclude_none=True) if hasattr(stage, "model_dump") else {}
        extra = stage.extra if hasattr(stage, "extra") else {}
        field_names: list[str] = []

        for key in ("field", "field1", "field2", "fields"):
            val = getattr(stage, key, None) or extra.get(key) or stage_data.get(key)
            if isinstance(val, list):
                field_names.extend(f for f in val if isinstance(f, str))
            elif isinstance(val, str):
                field_names.append(val)

        pairs = stage_data.get("pairs")
        if isinstance(pairs, list):
            for pair in pairs:
                if not isinstance(pair, dict):
                    continue
                for key in ("source", "target", "a", "b"):
                    ref = pair.get(key)
                    if isinstance(ref, str):
                        field_names.append(ref)

        prompt = stage_data.get("prompt")
        if isinstance(prompt, str):
            for m in re.finditer(r"\{\{\s*([^{}]+?)\s*\}\}", prompt):
                field_names.append(m.group(1).strip())

        for f in field_names:
            if f in relevant:
                continue
            if f not in bundle.fields:
                continue
            fv = bundle.fields[f]
            relevant[f] = fv.value if isinstance(fv, FieldValue) else fv

    return relevant


def _extract_relevant_field_positions(
    rule: Any,
    bundle: ExtractionBundle,
    *,
    inferred_positions: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Extract position data for fields referenced by a rule's stages."""
    inferred_positions = inferred_positions or {}
    positions: dict[str, Any] = {}
    if not rule or not hasattr(rule, "stages") or not rule.stages:
        return positions

    for stage in rule.stages:
        stage_data = stage.model_dump(exclude_none=True) if hasattr(stage, "model_dump") else {}
        extra = stage.extra if hasattr(stage, "extra") else {}
        field_names: list[str] = []

        for key in ("field", "field1", "field2", "fields"):
            val = getattr(stage, key, None) or extra.get(key) or stage_data.get(key)
            if isinstance(val, list):
                field_names.extend(f for f in val if isinstance(f, str))
            elif isinstance(val, str):
                field_names.append(val)

        pairs = stage_data.get("pairs")
        if isinstance(pairs, list):
            for pair in pairs:
                if not isinstance(pair, dict):
                    continue
                for key in ("source", "target", "a", "b"):
                    ref = pair.get(key)
                    if isinstance(ref, str):
                        field_names.append(ref)

        prompt = stage_data.get("prompt")
        if isinstance(prompt, str):
            for m in re.finditer(r"\{\{\s*([^{}]+?)\s*\}\}", prompt):
                field_names.append(m.group(1).strip())

        for f in field_names:
            if f in positions:
                continue
            fv = bundle.fields.get(f)
            if fv is not None and isinstance(fv, FieldValue):
                position_payload = _field_value_position_payload(fv) or inferred_positions.get(f)
                if position_payload is not None:
                    positions[f] = position_payload
    return positions


def _field_value_position_payload(fv: FieldValue) -> dict[str, Any] | None:
    """兼容原生 leaudit 新旧 FieldValue 结构，提取可落库的位置线索。"""
    position = getattr(fv, "position", None)
    if position is not None:
        if hasattr(position, "model_dump"):
            return position.model_dump(mode="json")
        if isinstance(position, dict):
            return position

    metadata = fv.metadata if isinstance(fv.metadata, dict) else {}
    payload: dict[str, Any] = {}

    if "match_position" in metadata:
        payload["matchPosition"] = metadata.get("match_position")
    if "matched_text" in metadata:
        payload["matchedText"] = metadata.get("matched_text")
    if "page_num" in metadata:
        payload["pageNum"] = metadata.get("page_num")
    if "page_nums" in metadata:
        payload["pageNums"] = metadata.get("page_nums")
    if "bbox" in metadata:
        payload["bbox"] = metadata.get("bbox")

    return payload or None


_POSITION_PUNCT_TABLE = str.maketrans({
    "，": ",", "。": ".", "；": ";", "：": ":",
    "！": "!", "？": "?", "（": "(", "）": ")",
    "【": "[", "】": "]", "「": '"', "」": '"',
    "‘": "'", "’": "'", "“": '"', "”": '"',
})


def _build_inferred_field_positions(
    bundle: ExtractionBundle,
    ocr_result: OcrResult | None,
) -> dict[str, dict[str, Any]]:
    """平台侧兜底补齐字段页码，避免 bridge 落库后丢失定位页。"""
    if ocr_result is None or not ocr_result.pages:
        return {}

    inferred: dict[str, dict[str, Any]] = {}
    page_texts = [
        (int(page.page_num) + 1, str(page.text or ""))
        for page in ocr_result.pages
        if str(page.text or "").strip()
    ]
    if not page_texts:
        return {}

    for field_name, field_value in bundle.fields.items():
        if not isinstance(field_value, FieldValue):
            continue
        if _field_value_position_payload(field_value) is not None:
            continue

        value_text = _stringify_position_value(field_value.value)
        if not value_text:
            continue

        chunk_match = _infer_position_from_chunks(value_text, ocr_result)
        if chunk_match is not None:
            inferred[field_name] = chunk_match
            continue

        page_num = _infer_page_num_from_page_texts(value_text, page_texts)
        if page_num is not None:
            inferred[field_name] = {
                "pageNum": page_num,
                "matchMethod": "platform_bridge_page_fallback",
            }

    return inferred


def _infer_position_from_chunks(
    value_text: str,
    ocr_result: OcrResult,
) -> dict[str, Any] | None:
    normalized_value = _normalize_position_text(value_text)
    if not normalized_value:
        return None

    for page in ocr_result.pages:
        for chunk in page.chunks or []:
            content = ""
            bbox = None
            if isinstance(chunk, dict):
                content = str(chunk.get("content") or "")
                bbox = chunk.get("bbox")
            else:
                content = str(getattr(chunk, "content", "") or "")
                bbox = getattr(chunk, "bbox", None)

            if not content:
                continue
            if normalized_value not in _normalize_position_text(content):
                continue

            payload: dict[str, Any] = {
                "pageNum": int(page.page_num) + 1,
                "matchMethod": "platform_bridge_chunk_fallback",
            }
            if bbox:
                payload["bbox"] = bbox
            return payload
    return None


def _infer_page_num_from_page_texts(
    value_text: str,
    page_texts: list[tuple[int, str]],
) -> int | None:
    normalized_value = _normalize_position_text(value_text)
    if not normalized_value:
        return None

    best_page: int | None = None
    best_score = 0.0
    min_length_for_fuzzy = 8

    for page_num, page_text in page_texts:
        normalized_page = _normalize_position_text(page_text)
        if not normalized_page:
            continue
        if normalized_value in normalized_page:
            return page_num

        if len(normalized_value) < min_length_for_fuzzy:
            continue

        score = _partial_similarity(normalized_value, normalized_page)
        if score > best_score:
            best_score = score
            best_page = page_num

    if best_score >= 0.92:
        return best_page
    return None


def _normalize_position_text(text: str) -> str:
    if not text:
        return ""
    normalized = re.sub(r"<[^>]+>", "", str(text))
    normalized = re.sub(r"\s+", "", normalized)
    return normalized.translate(_POSITION_PUNCT_TABLE)


def _partial_similarity(needle: str, haystack: str) -> float:
    if not needle or not haystack:
        return 0.0
    if len(needle) > len(haystack):
        needle, haystack = haystack, needle

    window = len(needle)
    if window <= 0:
        return 0.0
    if needle == haystack:
        return 1.0

    best = 0.0
    step = max(1, window // 6)
    stop = max(len(haystack) - window + 1, 1)
    for start in range(0, stop, step):
        chunk = haystack[start : start + window]
        if not chunk:
            continue
        matches = sum(1 for left, right in zip(needle, chunk) if left == right)
        best = max(best, matches / window)
        if best >= 0.999:
            return 1.0
    return best


def _stringify_position_value(raw_value: Any) -> str:
    if raw_value is None:
        return ""
    if isinstance(raw_value, str):
        return raw_value.strip()
    if isinstance(raw_value, (int, float, bool)):
        return str(raw_value)
    if isinstance(raw_value, dict):
        for key in ("value", "text", "value_text"):
            value = raw_value.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
        return ""
    if isinstance(raw_value, list):
        parts = [_stringify_position_value(item) for item in raw_value]
        return " ".join(part for part in parts if part).strip()
    return str(raw_value).strip()


def _rule_result_to_row(
    document_id: int,
    run_id: int | None,
    rule_result: RuleResult,
    rule: Any | None,
    bundle: ExtractionBundle,
    *,
    inferred_positions: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Convert a RuleResult to a leaudit_rule_results row."""
    passed = rule_result.passed

    # Resolve messages: rule_result → rule definition
    pass_msg = ""
    fail_msg = ""
    if rule_result.messages:
        pass_msg = rule_result.messages.get("pass", "")
        fail_msg = rule_result.messages.get("fail", "")
    elif rule:
        from leaudit.dsl.schema import Rule as DslRule
        if isinstance(rule, DslRule) and rule.messages:
            pass_msg = rule.messages.get("pass", "")
            fail_msg = rule.messages.get("fail", "")

    # Extract relevant fields
    relevant_fields = _extract_relevant_fields(rule, bundle)

    # Remediation (if present)
    remediation = None
    if rule_result.remediation:
        remediation = rule_result.remediation.model_dump(mode="json")

    # Rule metadata (references_laws, etc.)
    rule_meta_data: dict[str, Any] = {}
    if rule:
        from leaudit.dsl.schema import Rule as DslRule
        if isinstance(rule, DslRule):
            if rule.references_laws:
                rule_meta_data["references_laws"] = rule.references_laws
            if rule.desc:
                rule_meta_data["desc"] = rule.desc
            if rule.group:
                rule_meta_data["group"] = rule.group

    return {
        "run_id": run_id,
        "document_id": document_id,
        "rule_id": rule_result.rule_id,
        "rule_name": rule_result.name,
        "risk": rule_result.risk or "medium",
        "score": rule_result.score,
        "passed": passed,
        "status": rule_result.status,
        "skip_reason": rule_result.skip_reason,
        "confidence": rule_result.confidence,
        "pass_message": pass_msg,
        "fail_message": fail_msg,
        "stages": [s.model_dump(mode="json") for s in (rule_result.stages or [])],
        "extracted_fields": relevant_fields,
        "field_positions": _extract_relevant_field_positions(
            rule,
            bundle,
            inferred_positions=inferred_positions,
        ),
        "remediation": remediation,
        "rule_meta": rule_meta_data,
    }
