"""
Data integrity tests: cross-module consistency, module completeness,
and data validation.

Covers:
  1. Module registry completeness (B3-9)
  2. Strong's cross-module consistency (B3-1)
  3. Canon data integrity
  4. File-to-entry correlation
  5. Translation consistency
  6. Lectionary season consistency
"""

from __future__ import annotations

import os
import re
from pathlib import Path

import pytest

from accordance_parser.discovery import discover_modules
from accordance_parser.canon import (
    CANON_PROTESTANT,
    CANON_CATHOLIC,
    BOOKS_BY_OSIS,
    resolve_book,
    chapter_count,
    parse_reference,
)
from accordance_parser.lectionary import _LECTIONARY, get_lectionary_for_date_string


# =============================================================================
# 1. Module registry completeness (B3-9)
# =============================================================================

_REQUIRED_FIELDS = {"abbr", "name", "type", "path", "kind"}


def test_discover_modules_returns_dict():
    """discover_modules() returns a non-empty dict."""
    registry = discover_modules()
    assert isinstance(registry, dict), "Should return a dict"
    assert len(registry) > 0, "Registry should not be empty"


def test_modules_have_required_fields():
    """Every discovered module has all required fields."""
    registry = discover_modules()
    for abbr, mod in registry.items():
        missing = _REQUIRED_FIELDS - set(mod.keys())
        assert not missing, f"Module '{abbr}' missing fields: {missing}"


def test_no_duplicate_abbreviations():
    """No two modules share the same abbreviation."""
    registry = discover_modules()
    # The registry IS keyed by abbreviation, so duplicates are impossible.
    # This test confirms the keys match the stored 'abbr' field.
    for abbr, mod in registry.items():
        assert mod["abbr"] == abbr, (
            f"Registry key '{abbr}' doesn't match stored abbr '{mod['abbr']}'"
        )


def test_module_kind_counts():
    """Verify counts > 0 for at least Text and Tool kinds."""
    registry = discover_modules()
    kinds: dict[str, int] = {}
    for mod in registry.values():
        k = mod["kind"]
        kinds[k] = kinds.get(k, 0) + 1

    assert kinds.get("Text", 0) > 0, "Expected at least one Text module"
    assert kinds.get("Tool", 0) > 0, "Expected at least one Tool module"


def test_module_paths_exist():
    """Every discovered module's data file path exists on disk."""
    registry = discover_modules()
    missing = []
    for abbr, mod in registry.items():
        p = Path(mod["path"])
        if not p.exists():
            missing.append(f"{abbr}: {mod['path']}")
    assert not missing, f"Modules with missing paths: {missing}"


def test_module_paths_are_nonzero():
    """Every discovered module's data file has non-zero size."""
    registry = discover_modules()
    empty = []
    for abbr, mod in registry.items():
        p = Path(mod["path"])
        if p.exists() and p.stat().st_size == 0:
            empty.append(abbr)
    assert not empty, f"Modules with zero-byte files: {empty}"


# =============================================================================
# 2. Strong's cross-module consistency (B3-1)
# =============================================================================

# Check if KJV JSON data is available for Strong's tests
_STRONGS_SKIP_REASON: str | None = None


def _kjv_json_available() -> bool:
    """Check if any KJV JSON path exists."""
    global _STRONGS_SKIP_REASON
    if _STRONGS_SKIP_REASON is not None:
        return _STRONGS_SKIP_REASON == ""

    candidates = [
        Path(__file__).resolve().parent / "data" / "kjv.json",
        Path("/Users/jacobgruber/Projects/Citation App/backend/app/data/kjv.json"),
        Path.home() / ".accordance" / "kjv.json",
    ]
    for p in candidates:
        if p.exists():
            _STRONGS_SKIP_REASON = ""
            return True
    _STRONGS_SKIP_REASON = "KJV JSON data not found"
    return False


def _require_kjv():
    """Skip test if KJV JSON is not available."""
    if not _kjv_json_available():
        pytest.skip(_STRONGS_SKIP_REASON)


STRONGS_RE = re.compile(r"^[HG]\d{4}$")


def test_strongs_gen1_1():
    """Verify Strong's for Gen 1:1 include expected H-prefix numbers."""
    _require_kjv()
    from accordance_parser.bible_api import lookup_cached_verse

    result = lookup_cached_verse("kjv", 1, 1, 1)  # Gen 1:1
    assert result is not None, "lookup_cached_verse returned None for Gen 1:1"
    assert len(result.strongs) > 0, "Gen 1:1 should have Strong's numbers"

    # Gen 1:1 should contain at least 2 of: H7225, H1254, H0430
    expected = {"H7225", "H1254", "H0430"}
    found = set(result.strongs)
    matches = found & expected
    assert len(matches) >= 2, (
        f"Gen 1:1 Strong's: expected at least 2 of {expected}, got {found}"
    )


def test_strongs_john3_16():
    """Verify Strong's for John 3:16 include expected G-prefix numbers."""
    _require_kjv()
    from accordance_parser.bible_api import lookup_cached_verse

    result = lookup_cached_verse("kjv", 43, 3, 16)  # John 3:16
    assert result is not None, "lookup_cached_verse returned None for John 3:16"
    assert len(result.strongs) > 0, "John 3:16 should have Strong's numbers"

    # John 3:16 should contain G2316, G25 (or similar G-prefix words)
    strongs_set = set(result.strongs)
    g_words = {s for s in strongs_set if s.startswith("G")}
    assert len(g_words) >= 2, (
        f"John 3:16: expected at least 2 G-prefix Strong's, got {g_words}"
    )


def test_strongs_format():
    """All Strong's numbers match [HG]\\d{4} format."""
    _require_kjv()
    from accordance_parser.bible_api import lookup_cached_verse

    # Sample several verses across testaments
    verses = [
        (1, 1, 1),     # Gen 1:1 (OT)
        (19, 23, 1),   # Psalm 23:1 (OT)
        (40, 1, 1),    # Matt 1:1 (NT)
        (43, 3, 16),   # John 3:16 (NT)
        (66, 22, 20),  # Rev 22:20 (NT)
    ]
    for book_id, ch, v in verses:
        result = lookup_cached_verse("kjv", book_id, ch, v)
        if result is None:
            continue
        for sn in result.strongs:
            assert STRONGS_RE.match(sn), (
                f"Invalid Strong's format: '{sn}' in {book_id}:{ch}:{v}"
            )


# =============================================================================
# 3. Canon data integrity
# =============================================================================


def test_protestant_canon_has_66_books():
    """CANON_PROTESTANT contains exactly 66 books."""
    assert len(CANON_PROTESTANT.books) == 66, (
        f"Expected 66 books in Protestant canon, got {len(CANON_PROTESTANT.books)}"
    )


def test_catholic_canon_has_73_books():
    """CANON_CATHOLIC contains exactly 73 books."""
    assert len(CANON_CATHOLIC.books) == 73, (
        f"Expected 73 books in Catholic canon, got {len(CANON_CATHOLIC.books)}"
    )


def test_all_books_have_required_fields():
    """Every book in BOOKS_BY_OSIS has non-empty osis, name, and a bolls_id."""
    for osis_key, book in BOOKS_BY_OSIS.items():
        assert book.osis, f"Book '{osis_key}' has empty osis"
        assert book.name, f"Book '{osis_key}' has empty name"
        # Only proto-canonical books require bolls_id; deuterocanonicals may be None
        if osis_key in {b.osis for b in CANON_PROTESTANT.books}:
            assert book.bolls_id is not None, (
                f"Protestant canon book '{osis_key}' missing bolls_id"
            )


def test_resolve_book_genesis():
    """resolve_book('Genesis') returns Book with osis='Gen', bolls_id=1."""
    book = resolve_book("Genesis")
    assert book is not None, "resolve_book('Genesis') returned None"
    assert book.osis == "Gen", f"Expected osis='Gen', got '{book.osis}'"
    assert book.bolls_id == 1, f"Expected bolls_id=1, got {book.bolls_id}"


def test_single_chapter_books():
    """Obadiah, Philemon, 2 John, 3 John, Jude all have chapter_count() == 1."""
    single_chapter_osis = {"Obad", "Phlm", "2John", "3John", "Jude"}
    for osis in single_chapter_osis:
        count = chapter_count(osis, CANON_PROTESTANT)
        assert count == 1, f"Expected {osis} to have 1 chapter, got {count}"


def test_protestant_canon_contains_single_chapter_books():
    """Verify single-chapter books are in the Protestant canon."""
    protestant_osis = {b.osis for b in CANON_PROTESTANT.books}
    single_chapter_osis = {"Obad", "Phlm", "2John", "3John", "Jude"}
    for osis in single_chapter_osis:
        assert osis in protestant_osis, (
            f"'{osis}' not found in Protestant canon"
        )


# =============================================================================
# 4. File-to-entry correlation
# =============================================================================


def test_registry_file_sizes_reasonable():
    """Modules discovered in the registry have nonzero file sizes."""
    registry = discover_modules()
    suspicious = []
    for abbr, mod in registry.items():
        p = Path(mod["path"])
        if not p.exists():
            continue
        sz = p.stat().st_size
        if sz <= 0:
            suspicious.append(f"{abbr}: {sz} bytes")
    assert not suspicious, f"Zero-byte module files: {suspicious}"


def test_text_module_file_size_minimum():
    """Text modules should have file sizes > 1000 bytes."""
    registry = discover_modules()
    small = []
    for abbr, mod in registry.items():
        if mod["kind"] != "Text":
            continue
        p = Path(mod["path"])
        if not p.exists():
            continue
        sz = p.stat().st_size
        if sz <= 1000:
            small.append(f"{abbr}: {sz} bytes")
    assert not small, f"Text modules with tiny files: {small}"


def test_tool_module_file_size_minimum():
    """Tool modules should have file sizes > 1000 bytes."""
    registry = discover_modules()
    small = []
    for abbr, mod in registry.items():
        if mod["kind"] != "Tool":
            continue
        p = Path(mod["path"])
        if not p.exists():
            continue
        sz = p.stat().st_size
        if sz <= 1000:
            small.append(f"{abbr}: {sz} bytes")
    assert not small, f"Tool modules with tiny files: {small}"


def test_module_size_matches_os_path_getsize():
    """For a sample of modules, AccordanceModule.size matches os.path.getsize."""
    registry = discover_modules()
    # Pick up to 5 modules: 2 Text, 2 Tool, 1 LegacyTool
    samples: list[str] = []
    kinds_used: set[str] = set()
    for abbr, mod in registry.items():
        if mod["kind"] not in kinds_used or len(samples) < 5:
            samples.append(abbr)
            kinds_used.add(mod["kind"])
        if len(samples) >= 5:
            break

    if not samples:
        pytest.skip("No modules available for size check")

    from accordance_parser.module import AccordanceModule

    failures = []
    for abbr in samples:
        mod_entry = registry[abbr]
        path = mod_entry["path"]
        try:
            mod = AccordanceModule(path)
        except Exception as e:
            failures.append(f"{abbr}: failed to load ({e})")
            continue
        try:
            actual_size = os.path.getsize(path)
            if mod.size != actual_size:
                failures.append(
                    f"{abbr}: module.size={mod.size}, os.path.getsize={actual_size}"
                )
        finally:
            try:
                mod.close()
            except Exception:
                pass
    assert not failures, f"Size mismatches: {failures}"


# =============================================================================
# 5. Translation consistency
# =============================================================================


def _require_kjv_or_skip():
    """Skip test if KJV JSON is not available."""
    if not _kjv_json_available():
        pytest.skip(_STRONGS_SKIP_REASON)


def test_kjv_gen1_1_text():
    """get_verse('Genesis 1:1', 'kjv') returns non-empty text with expected content."""
    _require_kjv_or_skip()
    from accordance_parser.bible_api import get_verse

    result = get_verse("Genesis 1:1", translation="kjv")
    assert result is not None, "get_verse returned None for Genesis 1:1"
    assert result.text, "Verse text is empty"
    assert "beginning" in result.text.lower(), (
        f"Missing 'beginning' in Gen 1:1: {result.text[:80]}..."
    )
    assert "God" in result.text, (
        f"Missing 'God' in Gen 1:1: {result.text[:80]}..."
    )
    # Verify no raw tags leaked through
    assert "<S>" not in result.text, f"Raw <S> tags in text: {result.text[:80]}..."
    assert "<sup>" not in result.text, f"Raw <sup> tags in text: {result.text[:80]}..."


def test_kjv_john3_16_text():
    """get_verse('John 3:16', 'kjv') returns non-empty recognizable Bible text."""
    _require_kjv_or_skip()
    from accordance_parser.bible_api import get_verse

    result = get_verse("John 3:16", translation="kjv")
    assert result is not None, "get_verse returned None for John 3:16"
    assert result.text, "Verse text is empty"
    assert len(result.text.split()) >= 5, (
        f"John 3:16 text too short: {result.text[:80]}..."
    )
    # No raw tags
    assert "<S>" not in result.text
    assert "<sup>" not in result.text


def test_kjv_verse_contains_readable_english():
    """KJV verses contain readable English, not just tags or garbage."""
    _require_kjv_or_skip()
    from accordance_parser.bible_api import get_verse

    # Test a few well-known verses
    test_refs = [
        ("Genesis 1:1", "beginning"),
        ("Psalm 23:1", "shepherd"),
        ("John 3:16", "loved"),
    ]
    for ref, keyword in test_refs:
        result = get_verse(ref, translation="kjv")
        assert result is not None, f"get_verse returned None for {ref}"
        assert keyword.lower() in result.text.lower(), (
            f"Missing '{keyword}' in {ref}: {result.text[:80]}..."
        )


# =============================================================================
# 6. Lectionary season consistency
# =============================================================================


def test_lectionary_has_entries():
    """The hardcoded lectionary has entries."""
    assert len(_LECTIONARY) > 0, "Lectionary dictionary is empty"


def test_lectionary_season_names_nonempty():
    """Every lectionary date has a non-empty season name."""
    for date_str, (season, readings) in _LECTIONARY.items():
        assert isinstance(season, str), (
            f"Season for {date_str} is not a string: {season!r}"
        )
        assert season.strip(), f"Season name is empty for {date_str}"


def test_lectionary_readings_are_dicts():
    """Every lectionary entry's readings are a non-empty dict."""
    for date_str, (season, readings) in _LECTIONARY.items():
        assert isinstance(readings, dict), (
            f"Readings for {date_str} is not a dict: {readings!r}"
        )
        assert len(readings) > 0, f"No readings for {date_str}"


def test_lectionary_readings_have_expected_keys():
    """Each day's readings dict has at least 'first' and 'gospel' keys."""
    for date_str, (season, readings) in _LECTIONARY.items():
        assert "first" in readings, f"{date_str} missing 'first' reading"
        assert "gospel" in readings, f"{date_str} missing 'gospel' reading"


def test_lectionary_simple_references_parse():
    """Readings that are simple references (no commas/semicolons) parse correctly.

    Readings with comma-separated verse ranges (e.g. 'Psalms 68:10-11, 20-21')
    or semicolons (e.g. 'Acts 22:30; 23:6-11') are excluded from this test
    since parse_reference expects a single reference string.
    """
    # Simple references: single book, chapter, optional range
    simple_refs = []

    for date_str, (season, readings) in _LECTIONARY.items():
        for key, ref in readings.items():
            # Skip placeholder readings
            if ref.startswith("(See "):
                continue
            # Skip references with commas or semicolons (compound refs)
            if "," in ref or ";" in ref:
                continue
            # Skip empty references
            if not ref.strip():
                continue
            simple_refs.append((date_str, key, ref))

    assert len(simple_refs) > 0, "No simple references found in lectionary"

    unparseable = []
    for date_str, key, ref in simple_refs:
        result = parse_reference(ref)
        if result is None:
            unparseable.append(f"{date_str} {key}: '{ref}'")

    assert not unparseable, f"Unparseable lectionary references: {unparseable}"


def test_lectionary_compound_references_are_well_formed():
    """Compound references (comma/semicolon) contain parsable sub-references."""
    compound_refs = []
    for date_str, (season, readings) in _LECTIONARY.items():
        for key, ref in readings.items():
            if ref.startswith("(See "):
                continue
            if "," in ref or ";" in ref:
                compound_refs.append((date_str, key, ref))

    # Split on semicolons and commas to get individual refs
    failures = []
    for date_str, key, ref in compound_refs:
        # Split on semicolons first, then commas within each part
        parts = [p.strip() for p in re.split(r"[;,]", ref)]
        for part in parts:
            if not part.strip():
                continue
            # A sub-reference like "20-21" or "11-12" after comma needs
            # the book+chapter prefix from the first part
            # Try parsing as-is first
            result = parse_reference(part)
            if result is None:
                # It's likely a verse-range fragment; mark as informational
                failures.append(f"{date_str} {key}: fragment '{part}' from '{ref}'")

    # Compound fragments that don't parse are informational, not errors.
    # Just assert that at least some parts parse.
    parsed_count = 0
    for date_str, key, ref in compound_refs:
        for part in re.split(r"[;,]", ref):
            if parse_reference(part.strip()) is not None:
                parsed_count += 1

    # We expect at least the first segment of each compound ref to parse
    assert parsed_count > 0, "No compound reference segments parsed"


def test_get_lectionary_for_date_string_valid():
    """get_lectionary_for_date_string returns valid results for hardcoded dates."""
    for date_str, (season, readings) in _LECTIONARY.items():
        result = get_lectionary_for_date_string(date_str)
        assert result is not None, (
            f"get_lectionary_for_date_string({date_str!r}) returned None"
        )
        r_season, r_readings = result
        assert r_season == season, (
            f"Season mismatch for {date_str}: {r_season!r} != {season!r}"
        )
        assert isinstance(r_readings, dict)


def test_get_lectionary_for_date_string_invalid():
    """get_lectionary_for_date_string returns None for invalid dates."""
    invalid_dates = [
        "not-a-date",
        "",
        "2026-13-01",   # invalid month
        "2026-02-30",   # invalid day
    ]
    for d in invalid_dates:
        result = get_lectionary_for_date_string(d)
        assert result is None, f"Expected None for '{d}', got {result!r}"
