"""
Tests for the legacy .acc2 flat-file parallel/harmony module parser.

Kills 19 surviving cosmic-ray mutants in ``accordance_parser/acc2_module.py``:
  - ZeroIterationForLoop (4 sites): Any test expecting non-empty loop output
  - LtE<->GtE (8 sites): Byte-boundary conditions at 0x30/0x39/0x61/0x7A
  - GtE<->LtE (2 sites): lower_count >= 5 threshold; null_pos >= 0 guard
  - And<->Or (1 site): Digit-prefix check collapse
  - Gt<->Lt (1 site): Multi-digit paragraph numbers
  - NotEq<->Eq (1 site): Space-after-digit check
  - Eq<->NotEq (1 site): get_paragraph linear search match
  - Lt<->Gt (1 site): Paragraph boundary condition

All tests operate on real .acc2 binary files from ``modules/Tools/``.
"""

import os
from pathlib import Path

import pytest

from accordance_parser.acc2_module import Acc2Module

# ---------------------------------------------------------------------------
# Module paths
# ---------------------------------------------------------------------------

_TOOLS_DIR = Path(__file__).resolve().parent.parent / "modules" / "Tools"

_SYNOPTICS_PATH = str(_TOOLS_DIR / "Synoptics.acc2")
_GOSPELS_PATH = str(_TOOLS_DIR / "Gospels.acc2")
_HARMONY_PATH = str(_TOOLS_DIR / "Harmony.acc2")

_ALL_ACC2 = [
    "Synoptics.acc2",
    "Gospels.acc2",
    "Harmony.acc2",
    "Epistles.acc2",
    "Q (Sayings).acc2",
    "OT in NT.acc2",
    "Old Testament.acc2",
]


# ---------------------------------------------------------------------------
# Module-scoped fixtures (load once)
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def synoptics():
    """Load Synoptics.acc2 — has paragraph 0 (boundary: byte 0x30)."""
    assert os.path.exists(_SYNOPTICS_PATH), f"Missing: {_SYNOPTICS_PATH}"
    return Acc2Module(_SYNOPTICS_PATH)


@pytest.fixture(scope="module")
def gospels():
    """Load Gospels.acc2 — largest, sequential unique paragraph numbers."""
    assert os.path.exists(_GOSPELS_PATH), f"Missing: {_GOSPELS_PATH}"
    return Acc2Module(_GOSPELS_PATH)


@pytest.fixture(scope="module")
def harmony():
    """Load Harmony.acc2 — has duplicate paragraph numbers (boundary case)."""
    assert os.path.exists(_HARMONY_PATH), f"Missing: {_HARMONY_PATH}"
    return Acc2Module(_HARMONY_PATH)


# =============================================================================
# 1. Construction & basic loading
# =============================================================================


def test_nonexistent_file_raises():
    """Acc2Module with a nonexistent filepath raises FileNotFoundError."""
    with pytest.raises(FileNotFoundError):
        Acc2Module("/nonexistent/path/file.acc2")


@pytest.mark.parametrize("filename", _ALL_ACC2)
def test_all_files_load_nonempty(filename):
    """Every .acc2 file produces at least 30 paragraph entries."""
    path = str(_TOOLS_DIR / filename)
    if not os.path.exists(path):
        pytest.skip(f"Module file not found: {path}")
    mod = Acc2Module(path)
    entries = mod.entries()
    assert len(entries) >= 30, (
        f"{filename}: expected >= 30 paragraphs, got {len(entries)}"
    )


# =============================================================================
# 2. _find_content mutants (lines 118-134)
# =============================================================================


def test_synoptics_has_paragraph_zero(synoptics):
    """Paragraph 0 (byte 0x30) found — kills LtE<->GtE(L121) lower-bound mutant.

    The digit scan ``0x30 <= b <= 0x39`` must accept byte 0x30 ('0').
    If either ``<=`` is flipped to ``>=``, '0' would be misclassified as
    non-digit at the boundary, causing ``_content_start`` to land elsewhere.
    """
    p = synoptics.get_paragraph(0)
    assert p is not None, "Paragraph 0 not found (content_start mislocated?)"
    assert "0 The Genealogy of Jesus" in p[2]


def test_gospels_paragraph_nine(gospels):
    """Paragraph 9 (byte 0x39) found — kills LtE<->GtE(L121) upper-bound mutant.

    The digit range ``b <= 0x39`` must accept '9'.  Flipping the upper
    ``<=`` to ``>=`` would reject byte 0x39, breaking detection of
    the paragraph-9 heading near the start of the content area.
    """
    p = gospels.get_paragraph(9)
    assert p is not None, "Paragraph 9 not found"
    assert "9 " in p[2]


def test_gospels_paragraph_ten_exists(gospels):
    """Multi-digit paragraph 10 found — kills Gt<->Lt + And<->Or (L124).

    ``pos > 0 and 0x30 <= self.mm[pos - 1] <= 0x39`` guards against
    matching the second or later digit of a multi-digit number (e.g. '0'
    within '10').  If ``pos > 0`` becomes ``pos < 0`` (always False), the
    guard collapses and '0' inside '10' matches as a paragraph start,
    producing wrong paragraphs.  If ``and`` becomes ``or``, the guard is
    always True (pos >= 0x1000 > 0), skipping every candidate.
    """
    entries = gospels.entries()
    nums = [e[0] for e in entries]
    assert 10 in nums, "Paragraph 10 missing (multi-digit detection broken)"


def test_synoptics_paragraph_text_has_lowercase(synoptics):
    """Paragraph text contains lowercase — kills GtE<->LtE(L134) + LtE<->GtE(L133×2).

    ``lower_count >= 5`` uses ``>=`` (lines 133-134).  Flipping to ``<``
    would select the first candidate with < 5 lowercase letters, which
    lives in the all-uppercase index area — yielding 0 paragraphs.
    The lowercase range ``0x61 <= b <= 0x7A`` (L133) has two ``<=``
    operators; boundary bytes 'a' (0x61) and 'z' (0x7A) must be counted.
    """
    entries = synoptics.entries()
    assert len(entries) > 0, "No paragraphs — content_start in index area?"

    # Verify at least one paragraph contains lowercase letters a-z
    sample_text = entries[0][2]
    lower = [ch for ch in sample_text if "a" <= ch <= "z"]
    assert len(lower) >= 5, (
        f"Expected >= 5 lowercase letters in paragraph 0 text, "
        f"got {len(lower)}: {sample_text!r}"
    )


def test_gospels_content_has_lowercase_z_boundary(gospels):
    """Text contains 'z' — kills LtE<->GtE(L133) upper-bound specifically.

    The scan ``b <= 0x7A`` must accept 'z'.  Flipping to ``>=`` would
    reject it, potentially lowering the count below threshold.
    """
    entries = gospels.entries()
    all_text = " ".join(e[2] for e in entries)
    assert "z" in all_text.lower(), "No 'z' found in Gospels paragraphs"


# =============================================================================
# 3. _parse_paragraphs mutants (lines 157-161)
# =============================================================================


def test_adjacent_paragraphs_are_disjoint(gospels):
    """Paragraph N text does NOT contain paragraph N+1 text — kills Lt<->Gt(L161).

    ``end = matches[i + 1].start() if i + 1 < len(matches) else len(text)``
    uses ``<`` to pick the next-match boundary for all but the last
    paragraph.  If ``<`` becomes ``>``, the condition is True only for the
    last paragraph; all earlier paragraphs get ``len(text)`` as their
    end — swallowing every subsequent paragraph.

    This test also kills ZeroIterationForLoop(L157): without the loop
    body, there would be zero entries.
    """
    entries = gospels.entries()
    assert len(entries) >= 3, "Need >= 3 paragraphs for this test"

    # Paragraph 2 text must not contain paragraph 3 text
    p2 = entries[1][2]  # 0-indexed: entry 1 is paragraph 2
    p3 = entries[2][2]
    assert p3 not in p2, (
        f"Paragraph 2 text contains paragraph 3 text — boundary broken.\n"
        f"  p2: {p2!r}\n  p3: {p3!r}"
    )

    # Also check paragraph 4 does not bleed into paragraph 5
    p4 = gospels.get_paragraph(4)
    p5 = gospels.get_paragraph(5)
    assert p4 is not None and p5 is not None, "Paragraphs 4/5 not found"
    assert p5[2] not in p4[2], (
        "Paragraph 4 text contains paragraph 5 text — boundary broken."
    )


# =============================================================================
# 4. get_paragraph mutants (lines 194-195)
# =============================================================================


def test_get_paragraph_returns_correct_number(gospels):
    """get_paragraph(5) returns paragraph 5, not paragraph 1 — kills Eq<->NotEq(L195).

    ``if pnum == n`` must use ``==``.  If flipped to ``!=``, the linear
    scan returns the first paragraph whose number *differs* from the query,
    which for n=5 would be paragraph 1 (since 1 != 5).

    Also kills ZeroIterationForLoop(L194): without the loop body, every
    lookup returns None.
    """
    p = gospels.get_paragraph(5)
    assert p is not None, "get_paragraph(5) returned None"
    assert p[0] == 5, (
        f"Expected paragraph number 5, got {p[0]} (Eq->NotEq mutant active?)"
    )
    assert "5 " in p[2], "Paragraph text missing expected number prefix"


def test_get_paragraph_nonexistent_returns_none(gospels):
    """Out-of-range paragraph number returns None."""
    assert gospels.get_paragraph(99999) is None


def test_harmony_duplicate_paragraph_numbers(harmony):
    """File with duplicate paragraph numbers: first match returned.

    Harmony has paragraph 1 at index 0 and another at index 54.
    get_paragraph(1) returns the first match (index 0).
    """
    p = harmony.get_paragraph(1)
    assert p is not None
    assert p[0] == 1
    assert "Sources of the Gospels" in p[2], (
        f"Expected first paragraph-1 entry, got: {p[2]!r}"
    )


# =============================================================================
# 5. _clean_text mutants (lines 211-213)
# =============================================================================


def test_mac_roman_right_single_quote_decoded(gospels):
    """Text contains U+2019 right single quote — kills ZeroIterationForLoop(L213).

    Mac Roman byte 0xD5 must be remapped to U+2019 ('\u2019') by the
    ``for byte_val, replacement in _MAC_ROMAN_MAP.items()`` loop.  If the
    loop body is removed (ZeroIterationForLoop), byte 0xD5 stays as the
    undecoded latin-1 character '\xd5' (Õ), and no U+2019 appears.
    """
    entries = gospels.entries()
    all_text = " ".join(e[2] for e in entries)
    assert "\u2019" in all_text, (
        "Right single quote (U+2019) not found — Mac Roman remapping loop "
        "may be inactive (ZeroIterationForLoop L213 mutant)."
    )


def test_paragraph_text_ends_with_complete_word(gospels):
    """Paragraph text is not truncated — kills GtE<->LtE(L211).

    ``null_pos >= 0`` guards against null-byte truncation.  None of the
    real .acc2 files have null bytes in their content area, so ``null_pos``
    is always -1.  If ``>= 0`` is flipped to ``< 0``, every paragraph
    loses its last character: ``text[:null_pos]`` → ``text[:-1]``.

    Verify that a known paragraph ends with its complete final word
    rather than being truncated.
    """
    p4 = gospels.get_paragraph(4)
    assert p4 is not None
    text = p4[2]
    assert len(text) >= 3, f"Paragraph 4 text too short: {text!r}"
    # The final character must be a letter, not a cut-off word fragment
    final_char = text[-1]
    assert final_char.isalpha(), (
        f"Paragraph 4 ends with non-alpha char {final_char!r} — "
        f"text may be truncated (null-pos guard mutant): {text!r}"
    )


def test_all_paragraphs_end_with_alpha_or_paren(synoptics):
    """No paragraph ends mid-word across the entire Synoptics module.

    Strengthens the null-truncation guard test by checking all paragraphs.
    """
    for _pnum, _title, text in synoptics.entries():
        final = text[-1] if text else ""
        # Accept letters, digits, period, parenthesis, or em dash as
        # reasonable final characters that could terminate a title/heading
        assert final.isalpha() or final.isdigit() or final in ".).\u2014", (
            f"Paragraph ends with suspicious char {final!r}: {text!r}"
        )


# =============================================================================
# 6. Cross-file verification
# =============================================================================


@pytest.mark.parametrize("filename", _ALL_ACC2)
def test_no_null_bytes_in_any_file(filename):
    """No paragraph text in any .acc2 file contains embedded null bytes."""
    path = str(_TOOLS_DIR / filename)
    if not os.path.exists(path):
        pytest.skip(f"Module file not found: {path}")
    mod = Acc2Module(path)
    for pnum, _title, text in mod.entries():
        assert "\x00" not in text, (
            f"{filename} paragraph {pnum} contains null byte: {text!r}"
        )
