from __future__ import annotations

import statistics
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable

from PIL import Image

from .util import load_json, project_root, sha256_file


@dataclass(frozen=True)
class TsvWord:
    word_id: str
    block_num: int
    par_num: int
    line_num: int
    word_num: int
    left: int
    top: int
    width: int
    height: int
    confidence: float
    text: str


class SourcePackageError(ValueError):
    pass


class SourcePackage:
    def __init__(self, root: Path, lock_path: Path | None = None) -> None:
        self.root = root.resolve()
        self.lock_path = lock_path or project_root() / "source" / "package-lock.json"
        self.lock = load_json(self.lock_path)
        self.manifest_path = self.root / "manifest.json"
        self.source_meta_path = self.root / "meta" / "source.json"
        self.ocr_summary_path = self.root / "meta" / "ocr-summary.json"
        self.manifest = load_json(self.manifest_path)
        self.manifest_by_page = {row["page"]: row for row in self.manifest}

    @property
    def page_count(self) -> int:
        return int(self.lock["source_pages"])

    def page_id(self, page: int) -> str:
        self.require_page(page)
        return f"page-{page:03d}"

    def require_page(self, page: int) -> None:
        if page < 1 or page > self.page_count:
            raise SourcePackageError(f"Page {page} is outside 1-{self.page_count}")

    def image_relative(self, page: int) -> str:
        self.require_page(page)
        return self.lock["page_image_pattern"].format(page=page)

    def ocr_text_relative(self, page: int) -> str:
        self.require_page(page)
        return self.lock["ocr_text_pattern"].format(page=page)

    def ocr_tsv_relative(self, page: int) -> str:
        self.require_page(page)
        return self.lock["ocr_tsv_pattern"].format(page=page)

    def image_path(self, page: int) -> Path:
        return self.root / self.image_relative(page)

    def ocr_text_path(self, page: int) -> Path:
        return self.root / self.ocr_text_relative(page)

    def ocr_tsv_path(self, page: int) -> Path:
        return self.root / self.ocr_tsv_relative(page)

    def ocr_text(self, page: int) -> str:
        return self.ocr_text_path(page).read_text(encoding="utf-8")

    def tsv_words(self, page: int) -> list[TsvWord]:
        """Parse Tesseract TSV literally; quote characters have no CSV semantics."""
        words: list[TsvWord] = []
        with self.ocr_tsv_path(page).open(encoding="utf-8") as stream:
            header = stream.readline().rstrip("\n").split("\t")
            expected = [
                "level",
                "page_num",
                "block_num",
                "par_num",
                "line_num",
                "word_num",
                "left",
                "top",
                "width",
                "height",
                "conf",
                "text",
            ]
            if header != expected:
                raise SourcePackageError(
                    f"Unexpected TSV header on page {page}: {header!r}"
                )
            for source_line, line in enumerate(stream, start=2):
                fields = line.rstrip("\n").split("\t", 11)
                if len(fields) != 12:
                    raise SourcePackageError(
                        f"Malformed TSV page {page}, line {source_line}: {fields!r}"
                    )
                if fields[0] != "5" or not fields[11].strip():
                    continue
                block_num, par_num, line_num, word_num = map(int, fields[2:6])
                words.append(
                    TsvWord(
                        word_id=(
                            f"p{page:03d}-b{block_num:03d}-r{par_num:03d}"
                            f"-l{line_num:03d}-w{word_num:03d}"
                        ),
                        block_num=block_num,
                        par_num=par_num,
                        line_num=line_num,
                        word_num=word_num,
                        left=int(fields[6]),
                        top=int(fields[7]),
                        width=int(fields[8]),
                        height=int(fields[9]),
                        confidence=float(fields[10]),
                        text=fields[11],
                    )
                )
        return words

    def segment_for_page(self, page: int) -> dict[str, Any]:
        self.require_page(page)
        if 1 <= page <= 38:
            return {
                "id": "book-01",
                "kind": "book",
                "label_source": "First book",
                "assignment_status": "explicit" if page == 1 else "inferred",
            }
        if page == 39:
            return {
                "id": "separator-01",
                "kind": "separator",
                "label_source": None,
                "assignment_status": "inferred",
            }
        if 40 <= page <= 61:
            return {
                "id": "book-02",
                "kind": "book",
                "label_source": "Second book",
                "assignment_status": "explicit" if page == 40 else "inferred",
            }
        if page == 62:
            return {
                "id": "separator-02",
                "kind": "separator",
                "label_source": None,
                "assignment_status": "inferred",
            }
        if 63 <= page <= 94:
            return {
                "id": "book-03",
                "kind": "book",
                "label_source": "Third book",
                "assignment_status": "explicit" if page == 63 else "inferred",
            }
        if page == 95:
            return {
                "id": "separator-03",
                "kind": "separator",
                "label_source": None,
                "assignment_status": "inferred",
            }
        if 96 <= page <= 108:
            return {
                "id": "book-04",
                "kind": "book",
                "label_source": "Fourth book",
                "assignment_status": "explicit" if page == 96 else "inferred",
            }
        if page == 109:
            return {
                "id": "separator-04",
                "kind": "separator",
                "label_source": None,
                "assignment_status": "inferred",
            }
        if page == 110:
            return {
                "id": "gap-1897-07-25--1897-11-28",
                "kind": "source_gap",
                "label_source": "One book skipped - from July 25, 1897 to Nov. 28.",
                "assignment_status": "explicit",
            }
        return {
            "id": "book-05",
            "kind": "book",
            "label_source": "Last book",
            "assignment_status": "explicit" if page == 111 else "inferred",
        }

    def source_evidence(self, page: int) -> dict[str, Any]:
        image_path = self.image_path(page)
        text_path = self.ocr_text_path(page)
        tsv_path = self.ocr_tsv_path(page)
        with Image.open(image_path) as image:
            width, height = image.size
        return {
            "pdf_page": page,
            "source_pdf_sha256": self.lock["source_sha256"],
            "image": {
                "path": self.image_relative(page),
                "sha256": sha256_file(image_path),
                "width": width,
                "height": height,
                "dpi": self.lock["image_dpi"],
            },
            "ocr": {
                "text_path": self.ocr_text_relative(page),
                "text_sha256": sha256_file(text_path),
                "tsv_path": self.ocr_tsv_relative(page),
                "tsv_sha256": sha256_file(tsv_path),
                "engine": self.lock["ocr_engine"],
            },
        }

    def verify(self, pages: Iterable[int] | None = None) -> dict[str, Any]:
        source_meta = load_json(self.source_meta_path)
        errors: list[str] = []
        warnings: list[str] = []
        if source_meta.get("source_sha256") != self.lock["source_sha256"]:
            errors.append("Source PDF hash in meta/source.json does not match package lock")
        if source_meta.get("source_pages") != self.page_count:
            errors.append("Source page count in meta/source.json does not match package lock")
        if len(self.manifest) != self.page_count:
            errors.append(
                f"Manifest has {len(self.manifest)} records; expected {self.page_count}"
            )

        selected = list(pages) if pages is not None else list(range(1, self.page_count + 1))
        page_audits: list[dict[str, Any]] = []
        mismatch_count = 0
        major_mismatch_count = 0
        confidence_mismatch_count = 0
        for page in selected:
            try:
                image_path = self.image_path(page)
                text_path = self.ocr_text_path(page)
                tsv_path = self.ocr_tsv_path(page)
                for path in (image_path, text_path, tsv_path):
                    if not path.is_file():
                        errors.append(f"Missing source file: {path.relative_to(self.root)}")
                if not all(path.is_file() for path in (image_path, text_path, tsv_path)):
                    continue
                with Image.open(image_path) as image:
                    dimensions = list(image.size)
                words = self.tsv_words(page)
                confidences = [word.confidence for word in words]
                recomputed_mean = (
                    round(statistics.fmean(confidences), 2) if confidences else None
                )
                recomputed_median = (
                    round(statistics.median(confidences), 2) if confidences else None
                )
                supplied = self.manifest_by_page.get(page, {})
                supplied_count = supplied.get("ocr_word_count")
                difference = (
                    len(words) - supplied_count if isinstance(supplied_count, int) else None
                )
                if difference:
                    mismatch_count += 1
                    if abs(difference) >= 20:
                        major_mismatch_count += 1
                supplied_mean = supplied.get("ocr_mean_confidence")
                if (
                    supplied_mean is not None
                    and recomputed_mean is not None
                    and abs(float(supplied_mean) - recomputed_mean) >= 1.0
                ):
                    confidence_mismatch_count += 1
                page_audits.append(
                    {
                        "page": page,
                        "dimensions": dimensions,
                        "blank_candidate_supplied": supplied.get("blank_candidate"),
                        "supplied_word_count": supplied_count,
                        "recomputed_word_count": len(words),
                        "word_count_difference": difference,
                        "supplied_mean_confidence": supplied_mean,
                        "recomputed_mean_confidence": recomputed_mean,
                        "recomputed_median_confidence": recomputed_median,
                    }
                )
            except (OSError, ValueError, SourcePackageError) as exc:
                errors.append(f"Page {page}: {exc}")

        if mismatch_count:
            warnings.append(
                f"{mismatch_count} manifest word counts differ from literal TSV parsing; "
                f"{major_mismatch_count} differ by at least 20 words"
            )
        if confidence_mismatch_count:
            warnings.append(
                f"{confidence_mismatch_count} manifest mean confidences differ by at least 1 point"
            )
        return {
            "source_root": str(self.root),
            "page_count": self.page_count,
            "checked_pages": selected,
            "status": "invalid" if errors else "valid_with_warnings" if warnings else "valid",
            "errors": errors,
            "warnings": warnings,
            "summary": {
                "word_count_mismatches": mismatch_count,
                "major_word_count_mismatches": major_mismatch_count,
                "mean_confidence_mismatches_ge_1": confidence_mismatch_count,
            },
            "pages": page_audits,
        }

