#!/usr/bin/env python3
"""Bounded local book metadata and ISBN scanner for the Silo trusted client.

The scanner runs before encryption or network access.  It inspects only a bounded sample of
generic files, or the small metadata package inside an EPUB, and returns sanitized ISBN/title/
author hints.  It never uploads plaintext, sends a provider request, or treats metadata as proof
that two files are identical; the Silo server remains authoritative for intake and deduplication.

Self-test: python3 silo_book_scanner.py --selftest
"""

from __future__ import annotations

import argparse
import json
import pathlib
import re
import tempfile
import zipfile
import xml.etree.ElementTree as _ET


MAX_SAMPLE_BYTES = 4 * 1024 * 1024
MAX_EPUB_ENTRY_BYTES = 1 * 1024 * 1024
_ISBN_TOKEN = re.compile(
    r"(?<![0-9X])[0-9X][0-9X\s-]{8,20}[0-9X](?![0-9X])",
    re.IGNORECASE,
)
_YEAR = re.compile(r"\b(1[4-9][0-9]{2}|20[0-9]{2}|21[0-9]{2})\b")
_FORMAT_BY_SUFFIX = {
    ".pdf": "pdf",
    ".epub": "epub",
    ".mobi": "ebook",
    ".azw": "ebook",
    ".azw3": "ebook",
    ".cbz": "comic",
    ".cbr": "comic",
}


def normalize_isbn(value) -> str | None:
    """Return a validated ISBN-13, or None when the value is absent."""
    if value is None or not str(value).strip():
        return None
    raw = re.sub(r"[^0-9X]", "", str(value).upper())
    if len(raw) == 10:
        if any(char == "X" for char in raw[:-1]):
            raise ValueError("ISBN-10 checksum is invalid")
        total = sum((10 - index) * (10 if char == "X" else int(char))
                    for index, char in enumerate(raw))
        if total % 11:
            raise ValueError("ISBN-10 checksum is invalid")
        core = "978" + raw[:9]
        check = (10 - sum(
            (1 if index % 2 == 0 else 3) * int(char)
            for index, char in enumerate(core)
        ) % 10) % 10
        return core + str(check)
    if len(raw) == 13 and raw.isdigit() and raw[:3] in {"978", "979"}:
        total = sum(
            (1 if index % 2 == 0 else 3) * int(char)
            for index, char in enumerate(raw)
        )
        if total % 10 == 0:
            return raw
    raise ValueError("ISBN must be a valid ISBN-10 or ISBN-13")


def _format_for(path: pathlib.Path) -> str:
    return _FORMAT_BY_SUFFIX.get(path.suffix.casefold(), "scan")


def _text(value, limit: int = 240) -> str:
    value = " ".join(str(value or "").split())
    return value[:limit]


def _year(value):
    match = _YEAR.search(str(value or ""))
    return int(match.group(1)) if match else None


def _isbn_candidates(text: str) -> list[str]:
    found = []
    seen = set()
    for match in _ISBN_TOKEN.finditer(text.upper()):
        try:
            candidate = normalize_isbn(match.group(0))
        except (TypeError, ValueError):
            continue
        if candidate and candidate not in seen:
            seen.add(candidate)
            found.append(candidate)
    return found


def _bounded_file_text(path: pathlib.Path) -> str:
    size = path.stat().st_size
    with path.open("rb") as handle:
        head = handle.read(MAX_SAMPLE_BYTES)
        tail = b""
        if size > MAX_SAMPLE_BYTES:
            handle.seek(max(0, size - MAX_SAMPLE_BYTES))
            tail = handle.read(MAX_SAMPLE_BYTES)
    return (head + b"\n" + tail).decode("latin-1", errors="ignore")


def _zip_entry(zf: zipfile.ZipFile, name: str) -> bytes:
    try:
        info = zf.getinfo(name)
    except KeyError:
        return b""
    if info.file_size < 0 or info.file_size > MAX_EPUB_ENTRY_BYTES:
        return b""
    return zf.read(info)


def _local_name(tag: str) -> str:
    return str(tag).rsplit("}", 1)[-1].casefold()


def _epub_metadata(path: pathlib.Path) -> tuple[dict, list[str]]:
    """Read bounded EPUB package metadata and return hints plus ISBN candidates."""
    try:
        with zipfile.ZipFile(path) as zf:
            container = _zip_entry(zf, "META-INF/container.xml")
            if not container:
                return {}, []
            root = _ET.fromstring(container)
            full_path = ""
            for item in root.iter():
                if _local_name(item.tag) == "rootfile":
                    full_path = str(item.attrib.get("full-path") or "").strip()
                    if full_path:
                        break
            if not full_path:
                return {}, []
            package = _zip_entry(zf, full_path)
            if not package:
                return {}, []
            package_root = _ET.fromstring(package)
    except (OSError, ValueError, zipfile.BadZipFile, _ET.ParseError):
        return {}, []

    metadata = {}
    identifiers = []
    for item in package_root.iter():
        name = _local_name(item.tag)
        value = _text(item.text)
        if not value:
            continue
        if name == "identifier":
            identifiers.append(value)
        elif name in {"title", "creator", "publisher", "language", "date"}:
            metadata.setdefault(name, value)
    candidates = _isbn_candidates("\n".join(identifiers))
    metadata = {
        "title": metadata.get("title", ""),
        "author": metadata.get("creator", ""),
        "publisher": metadata.get("publisher", ""),
        "language": metadata.get("language", ""),
        "publication_year": _year(metadata.get("date")),
    }
    return metadata, candidates


def scan_book(path) -> dict:
    """Return local-only identification hints for one book file."""
    source = pathlib.Path(path).expanduser().resolve()
    if not source.is_file() or source.is_symlink():
        raise ValueError("book path must be a regular local file")
    format_name = _format_for(source)
    metadata = {}
    evidence = []
    candidates = []

    if format_name == "epub":
        metadata, epub_candidates = _epub_metadata(source)
        if epub_candidates:
            candidates.extend(epub_candidates)
            evidence.append("epub-metadata")

    filename_candidates = _isbn_candidates(source.name)
    if filename_candidates:
        candidates.extend(filename_candidates)
        evidence.append("filename")

    # EPUB metadata is the preferred source.  The bounded sample is a fallback for PDF text,
    # scan sidecars, and other formats; it never reads a whole large file into memory.
    if not candidates:
        sample_candidates = _isbn_candidates(_bounded_file_text(source))
        if sample_candidates:
            candidates.extend(sample_candidates)
            evidence.append("bounded-file-text")

    unique_candidates = list(dict.fromkeys(candidates))
    result = {
        "format": format_name,
        "isbn": unique_candidates[0] if len(unique_candidates) == 1 else "",
        "isbn_candidates": unique_candidates,
        "ambiguous": len(unique_candidates) > 1,
        "title": _text(metadata.get("title")),
        "author": _text(metadata.get("author")),
        "publisher": _text(metadata.get("publisher")),
        "language": _text(metadata.get("language"), 40),
        "publication_year": metadata.get("publication_year"),
        "evidence": evidence,
        "confidence": (
            0.99 if "epub-metadata" in evidence and len(unique_candidates) == 1
            else 0.90 if "filename" in evidence and len(unique_candidates) == 1
            else 0.80 if "bounded-file-text" in evidence and len(unique_candidates) == 1
            else 0.0
        ),
    }
    if result["ambiguous"]:
        result["warning"] = "multiple valid ISBNs found; choose the edition explicitly"
    elif not result["isbn"]:
        result["warning"] = "no valid ISBN found locally; barcode or manual metadata is required"
    return result


def _selftest() -> int:
    checks = []

    def check(label, condition):
        checks.append((label, bool(condition)))

    with tempfile.TemporaryDirectory(prefix="havre-silo-book-scan-") as directory:
        root = pathlib.Path(directory)
        pdf = root / "Nineteen Eighty-Four.pdf"
        pdf.write_bytes(b"synthetic PDF text ISBN 978-0-452-28423-4")
        scanned_pdf = scan_book(pdf)
        check("bounded PDF text scanning normalizes one ISBN",
              scanned_pdf["isbn"] == "9780452284234"
              and scanned_pdf["ambiguous"] is False
              and scanned_pdf["evidence"] == ["bounded-file-text"])
        check("scanner reports local evidence only",
              "path" not in scanned_pdf and "plaintext" not in json.dumps(scanned_pdf))

        named = root / "named [9780451524935].pdf"
        named.write_bytes(b"synthetic file without embedded ISBN text")
        named_scan = scan_book(named)
        check("filename scanning normalizes one ISBN",
              named_scan["isbn"] == "9780451524935"
              and named_scan["evidence"] == ["filename"])

        epub = root / "metadata.epub"
        with zipfile.ZipFile(epub, "w", compression=zipfile.ZIP_DEFLATED) as zf:
            zf.writestr(
                "META-INF/container.xml",
                '<?xml version="1.0"?><container><rootfiles><rootfile '
                'full-path="OEBPS/content.opf"/></rootfiles></container>',
            )
            zf.writestr(
                "OEBPS/content.opf",
                '<package><metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
                '<dc:title>The Pilot Book</dc:title><dc:creator>A Havre Reader</dc:creator>'
                '<dc:publisher>Havre Press</dc:publisher><dc:language>eng</dc:language>'
                '<dc:date>2026-01-01</dc:date>'
                '<dc:identifier>urn:isbn:9780451524935</dc:identifier>'
                '</metadata></package>',
            )
        scanned_epub = scan_book(epub)
        check("EPUB metadata supplies the edition hints",
              scanned_epub["isbn"] == "9780451524935"
              and scanned_epub["title"] == "The Pilot Book"
              and scanned_epub["author"] == "A Havre Reader"
              and scanned_epub["publication_year"] == 2026
              and scanned_epub["confidence"] == 0.99)

        ambiguous = root / "two-isbns.pdf"
        ambiguous.write_bytes(b"ISBN 9780451524935 ISBN 9780452284234")
        ambiguous_scan = scan_book(ambiguous)
        check("multiple ISBNs fail closed for automatic selection",
              ambiguous_scan["ambiguous"] is True
              and ambiguous_scan["isbn"] == ""
              and len(ambiguous_scan["isbn_candidates"]) == 2)

        plain = root / "no-isbn.txt"
        plain.write_text("a scan without a barcode", encoding="utf-8")
        plain_scan = scan_book(plain)
        check("missing ISBN remains an explicit manual-review result",
              plain_scan["isbn"] == "" and plain_scan["confidence"] == 0.0
              and bool(plain_scan.get("warning")))

    passed = sum(result for _, result in checks)
    for label, result in checks:
        print(f"{'PASS' if result else 'FAIL'}: {label}")
    print(f"{passed}/{len(checks)} checks passed")
    return 0 if passed == len(checks) else 1


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("path", nargs="?")
    parser.add_argument("--json", action="store_true", dest="as_json")
    parser.add_argument("--selftest", action="store_true")
    args = parser.parse_args(argv)
    if args.selftest:
        return _selftest()
    if not args.path:
        parser.error("path is required unless --selftest is used")
    result = scan_book(args.path)
    print(json.dumps(result, indent=2, sort_keys=True) if args.as_json else result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
