from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any, Iterable

from .schema_tools import load_canonical_schema, schema_errors
from .source import SourcePackage
from .util import sha256_file


@dataclass(frozen=True)
class ValidationReport:
    valid: bool
    errors: list[str]
    warnings: list[str]

    def as_dict(self) -> dict[str, Any]:
        return {"valid": self.valid, "errors": self.errors, "warnings": self.warnings}


def _ordered(items: list[dict[str, Any]], label: str, errors: list[str]) -> None:
    orders = [item.get("order") for item in items]
    expected = list(range(1, len(items) + 1))
    if orders != expected:
        errors.append(f"{label} orders must be contiguous {expected}; found {orders}")


def _check_lines(
    lines: list[dict[str, Any]], label: str, errors: list[str]
) -> None:
    numbers = [line.get("line_number") for line in lines]
    expected = list(range(1, len(lines) + 1))
    if numbers != expected:
        errors.append(f"{label} line numbers must be contiguous {expected}; found {numbers}")
    for index, line in enumerate(lines):
        if index < len(lines) - 1 and line.get("line_end") == "block_end":
            errors.append(f"{label} line {index + 1} has block_end before the last line")
        for segment_index, segment in enumerate(line.get("segments", []), start=1):
            kind = segment.get("type")
            if kind in {"unclear", "addition", "deletion"}:
                unreadable_intervention = (
                    kind in {"addition", "deletion"}
                    and segment.get("confidence") == "unreadable"
                )
                if (
                    not segment.get("reading")
                    and not segment.get("alternatives")
                    and not unreadable_intervention
                ):
                    errors.append(
                        f"{label} line {index + 1} segment {segment_index} "
                        f"({kind}) needs a reading or alternative; visible additions "
                        "and deletions may instead use confidence=unreadable"
                    )
            if kind == "correction":
                if segment.get("original") is None and segment.get("replacement") is None:
                    errors.append(
                        f"{label} line {index + 1} correction must preserve an original or replacement"
                    )


def _walk_lines(block: dict[str, Any]) -> Iterable[tuple[str, list[dict[str, Any]]]]:
    kind = block["type"]
    if "lines" in block:
        yield f"block {block['id']}", block["lines"]
    if kind in {"diary_entry", "letter"}:
        for part in block["content"]:
            part_label = f"part {part['id']}"
            if "lines" in part:
                yield part_label, part["lines"]
            if part["type"] == "list":
                for item in part["items"]:
                    if item.get("label_lines"):
                        yield f"{part_label} item {item['order']} label", item["label_lines"]
                    yield f"{part_label} item {item['order']} content", item["content_lines"]
            if part["type"] == "table":
                for row in part["rows"]:
                    for cell in row["cells"]:
                        yield (
                            f"{part_label} row {row['order']} column {cell['column']}",
                            cell["lines"],
                        )
    if kind == "list":
        for item in block["items"]:
            if item.get("label_lines"):
                yield f"block {block['id']} item {item['order']} label", item["label_lines"]
            yield f"block {block['id']} item {item['order']} content", item["content_lines"]
    if kind == "table":
        for row in block["rows"]:
            for cell in row["cells"]:
                yield (
                    f"block {block['id']} row {row['order']} column {cell['column']}",
                    cell["lines"],
                )


def _check_date(source_date: dict[str, Any], label: str, errors: list[str]) -> None:
    normalized = source_date.get("date_normalized")
    status = source_date.get("normalization_status")
    confidence = source_date.get("normalization_confidence")
    reason = source_date.get("normalization_reason")
    if normalized:
        try:
            if date.fromisoformat(normalized).isoformat() != normalized:
                errors.append(f"{label} normalized date is not canonical ISO: {normalized}")
        except ValueError:
            errors.append(f"{label} normalized date is invalid: {normalized}")
    if status in {"normalized", "inferred"}:
        if not normalized or not confidence or not reason:
            errors.append(
                f"{label} status {status} requires normalized date, confidence, and reason"
            )
    elif status in {"not_normalized", "ambiguous", "invalid"} and normalized is not None:
        errors.append(f"{label} status {status} must not carry a normalized date")
    if status == "not_normalized" and (confidence is not None or reason is not None):
        errors.append(f"{label} not_normalized must not carry confidence or reason")


def _check_gap_endpoint_date(
    source_date: dict[str, Any] | None, label: str, errors: list[str]
) -> None:
    if source_date is None:
        return
    _check_date(source_date, label, errors)
    if (
        source_date.get("date_normalized") is not None
        and not re.search(
            r"(?:\b\d{4}\b|(?:[-/']\s*)\d{2}\s*[.]?$|\b\d{1,2}/\d{1,2}/\d{2}\b)",
            source_date["date_source"],
        )
        and source_date.get("normalization_status") != "inferred"
    ):
        errors.append(
            f"{label} supplies a year absent from date_source and must use status inferred"
        )


def validate_page(
    page: dict[str, Any], source: SourcePackage | None = None
) -> ValidationReport:
    errors = schema_errors(page, load_canonical_schema())
    warnings: list[str] = []
    if errors:
        return ValidationReport(False, errors, warnings)

    source_page = page["source"]["pdf_page"]
    expected_page_id = f"page-{source_page:03d}"
    if page["page_id"] != expected_page_id:
        errors.append(
            f"page_id {page['page_id']} does not match source page {source_page}"
        )
    expected_image = f"pages/page-{source_page:03d}.png"
    expected_text = f"ocr/page-{source_page:03d}.txt"
    expected_tsv = f"ocr/page-{source_page:03d}.tsv"
    if page["source"]["image"]["path"] != expected_image:
        errors.append(f"Image path must be {expected_image}")
    if page["source"]["ocr"]["text_path"] != expected_text:
        errors.append(f"OCR text path must be {expected_text}")
    if page["source"]["ocr"]["tsv_path"] != expected_tsv:
        errors.append(f"OCR TSV path must be {expected_tsv}")

    _ordered(page["blocks"], "block", errors)
    seen_ids: set[str] = set()
    block_ids = {block["id"] for block in page["blocks"]}
    for block in page["blocks"]:
        if block["id"] in seen_ids:
            errors.append(f"Duplicate block id: {block['id']}")
        seen_ids.add(block["id"])
        if block["type"] in {"diary_entry", "letter"}:
            _ordered(block["content"], f"block {block['id']} part", errors)
            if block.get("date"):
                _check_date(block["date"], f"block {block['id']} date", errors)
        if block["type"] == "gap_notice":
            _check_gap_endpoint_date(
                block["start_date"], f"block {block['id']} start_date", errors
            )
            _check_gap_endpoint_date(
                block["end_date"], f"block {block['id']} end_date", errors
            )
        if block["type"] == "list":
            _ordered(block["items"], f"block {block['id']} list item", errors)
        if block["type"] == "table":
            _ordered(block["rows"], f"block {block['id']} table row", errors)
            for row in block["rows"]:
                columns = [cell["column"] for cell in row["cells"]]
                if len(columns) != len(set(columns)):
                    errors.append(f"block {block['id']} row {row['order']} repeats a column")
                if any(column > block["column_count"] for column in columns):
                    errors.append(
                        f"block {block['id']} row {row['order']} exceeds column_count"
                    )
        if block["type"] == "graphical_feature":
            continuation = block["continuation"]
            if (
                continuation["from_previous"] != "no"
                or continuation["to_next"] != "no"
            ):
                errors.append(
                    f"block {block['id']} graphical feature must not assert "
                    "textual continuation"
                )
            missing_related = sorted(set(block["related_block_ids"]) - block_ids)
            if missing_related:
                errors.append(
                    f"block {block['id']} references missing related blocks "
                    f"{missing_related}"
                )
            region = block["region"]
            if region:
                image = page["source"]["image"]
                if region["x"] + region["width"] > image["width"]:
                    errors.append(
                        f"block {block['id']} graphical region exceeds image width"
                    )
                if region["y"] + region["height"] > image["height"]:
                    errors.append(
                        f"block {block['id']} graphical region exceeds image height"
                    )
        for label, lines in _walk_lines(block):
            _check_lines(lines, label, errors)
        for direction, delta in (("previous", -1), ("next", 1)):
            reference = block["continuation"][direction]
            if reference:
                target = int(reference["page_id"].split("-")[1])
                if target != source_page + delta:
                    errors.append(
                        f"block {block['id']} {direction} reference must point to adjacent page"
                    )
        evidence = block["evidence"]
        bbox = evidence["bbox"]
        if bbox:
            image = page["source"]["image"]
            if bbox["x"] + bbox["width"] > image["width"]:
                errors.append(f"block {block['id']} evidence bbox exceeds image width")
            if bbox["y"] + bbox["height"] > image["height"]:
                errors.append(f"block {block['id']} evidence bbox exceeds image height")

    for note in page["editorial_notes"]:
        target = note["target_block_id"]
        if target is not None and target not in block_ids:
            errors.append(f"Editorial note {note['id']} targets missing block {target}")

    if page["page_role"] == "blank" and page["blocks"]:
        errors.append("Blank pages must have no document blocks")
    if page["page_role"] == "gap_notice" and not any(
        block["type"] == "gap_notice" for block in page["blocks"]
    ):
        errors.append("gap_notice page requires a gap_notice block")
    if page["review"]["status"] in {"model_candidate", "agent_reviewed"}:
        if page["review"]["authority"] != "diagnostic_only":
            errors.append(
                "Model and agent-reviewed pages must remain authority=diagnostic_only"
            )
    if page["review"]["status"] == "owner_approved":
        if page["review"]["authority"] != "owner_approved_edition":
            errors.append("Owner-approved pages require owner_approved_edition authority")

    if source is not None:
        for relative, supplied_hash in (
            (page["source"]["image"]["path"], page["source"]["image"]["sha256"]),
            (page["source"]["ocr"]["text_path"], page["source"]["ocr"]["text_sha256"]),
            (page["source"]["ocr"]["tsv_path"], page["source"]["ocr"]["tsv_sha256"]),
        ):
            path = source.root / relative
            if not path.is_file():
                errors.append(f"Missing referenced source file: {relative}")
            elif sha256_file(path) != supplied_hash:
                errors.append(f"Source hash mismatch: {relative}")
        expected_segment = source.segment_for_page(source_page)
        if page["segment"] != expected_segment:
            errors.append(
                f"Segment assignment differs from deterministic segment map: {expected_segment}"
            )

    return ValidationReport(not errors, errors, warnings)


def validate_reciprocal_pages(pages: list[dict[str, Any]]) -> ValidationReport:
    errors: list[str] = []
    warnings: list[str] = []
    by_id = {page["page_id"]: page for page in pages}
    for page in pages:
        for block in page["blocks"]:
            for direction, reciprocal in (("next", "previous"), ("previous", "next")):
                reference = block["continuation"][direction]
                if not reference or reference["page_id"] not in by_id:
                    continue
                target_page = by_id[reference["page_id"]]
                target = next(
                    (item for item in target_page["blocks"] if item["id"] == reference["block_id"]),
                    None,
                )
                if target is None:
                    errors.append(
                        f"{block['id']} references missing block {reference['block_id']}"
                    )
                    continue
                back = target["continuation"][reciprocal]
                expected = {"page_id": page["page_id"], "block_id": block["id"]}
                if back != expected:
                    errors.append(
                        f"{block['id']}->{target['id']} is not reciprocal; expected {expected}, found {back}"
                    )
    return ValidationReport(not errors, errors, warnings)
