#!/usr/bin/env python3
"""
Tests for CLI argument handling, edge cases, and robustness.

Strategy:
  - Subprocess tests: only for pure-argparse scenarios (no module loading).
    These are fast (~0.1s each).
  - Direct function-call tests: for command functions that trigger
    _load_modules().  A module-scoped fixture pre-loads modules once
    (~45s), making all subsequent direct-call tests instant.
  - Reading-plan tests: pure functions, fast unit tests.

Covers:
  1. Missing / bogus arguments (A3-10)
  2. Module loading edge cases
  3. Reading plan tests (A3-4)
  4. CLI output format tests (A3-2 routine)
"""

from __future__ import annotations

import argparse
import subprocess
import sys
from pathlib import Path

import pytest

# ---------------------------------------------------------------------------
# Direct imports for unit tests and direct-call tests
# ---------------------------------------------------------------------------

from accordance_parser.cli import (
    main,
    COMMAND_MAP,
    build_parser,
    _load_modules,
    _get_plan_schedule,
    _get_plan_name,
    cmd_verse,
    cmd_lexicon,
    cmd_commentary,
    cmd_study,
    cmd_strongs,
    cmd_interlinear,
    cmd_compare,
    cmd_search,
)

# ---------------------------------------------------------------------------
# Module-level fixture: pre-load modules once so command-function tests are fast
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module", autouse=True)
def _warm_modules():
    """Pre-load Accordance modules once (takes ~45s, but only once per test run).

    All subsequent calls to _load_modules() or command functions that call it
    are effectively instant because the global cache is already populated.
    """
    _load_modules()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_ACCORDANCE = [sys.executable, "-m", "accordance_parser.cli"]

_PACKAGE_DIR = Path(__file__).resolve().parent.parent
_MODULES_DIR = _PACKAGE_DIR / "modules"


def _run_cli(*args: str, timeout: int = 15):
    """Run the CLI via subprocess.  Keep short timeout — only for pure-argparse
    tests that do NOT trigger module loading."""
    return subprocess.run(
        _ACCORDANCE + list(args),
        capture_output=True,
        text=True,
        timeout=timeout,
    )


def _modules_available() -> bool:
    """Check if local Accordance modules directory exists and has content."""
    if not _MODULES_DIR.exists():
        return False
    try:
        return any(True for _ in _MODULES_DIR.iterdir())
    except Exception:
        return False


def _make_args(**kwargs):
    """Build a simple argparse.Namespace from kwargs, adding defaults."""
    # Ensure all expected attributes exist with sensible defaults
    defaults = {
        "reference": "",
        "translation": "kjv",
        "translations": "KJV,ESV,NASB",
        "headword": "",
        "lexicon": "",
        "max_results": 20,
        "module": "",
        "query": "",
        "type": "all",
        "date": "",
        "command": None,
    }
    merged = {**defaults, **kwargs}
    return argparse.Namespace(**merged)


# =============================================================================
# Category 1: Missing / bogus arguments — subprocess (A3-10)
#
# These tests exercise ONLY argparse parsing — no module loading.
# They are fast (~0.1 s each).
# =============================================================================


class TestArgparseErrors:
    """Pure argparse error-handling via subprocess (fast — no module loading)."""

    def test_no_subcommand_shows_help(self):
        """Running CLI with no subcommand prints help and exits non-zero."""
        result = _run_cli()
        assert result.returncode != 0, (
            f"Expected non-zero exit for no subcommand, got {result.returncode}"
        )
        combined = (result.stdout + result.stderr).lower()
        assert any(
            word in combined for word in ["usage", "help", "command", "accordance"]
        ), f"Expected help/usage output, got: {combined[:300]}"

    def test_bogus_subcommand(self):
        """Bogus subcommand exits non-zero with an error message."""
        result = _run_cli("bogus", "x")
        assert result.returncode != 0, (
            f"Expected non-zero exit for bogus subcommand, got {result.returncode}"
        )
        combined = (result.stdout + result.stderr).lower()
        assert any(
            kw in combined for kw in ["invalid", "unknown", "bogus", "error"]
        ), f"Expected error message, got: {combined[:300]}"

    def test_verse_missing_reference(self):
        """'verse' without required positional arg exits non-zero."""
        result = _run_cli("verse")
        assert result.returncode != 0, (
            f"Expected non-zero exit for missing reference, got {result.returncode}"
        )
        combined = (result.stdout + result.stderr).lower()
        assert any(
            kw in combined for kw in ["error", "argument", "usage"]
        ), f"Expected error for missing argument, got: {combined[:300]}"

    def test_daily_invalid_date(self):
        """'daily --date not-a-date' exits non-zero gracefully (no module loading)."""
        result = _run_cli("daily", "--date", "not-a-date")
        assert result.returncode != 0, (
            f"Expected non-zero exit for invalid date, got {result.returncode}"
        )
        stderr_lower = result.stderr.lower()
        assert "invalid" in stderr_lower or "date" in stderr_lower, (
            f"Expected 'invalid date' error, got stderr: {result.stderr[:200]}"
        )

    def test_daily_impossible_date(self):
        """'daily --date 2026-02-30' exits non-zero (feb 30 impossible)."""
        result = _run_cli("daily", "--date", "2026-02-30")
        assert result.returncode != 0, (
            f"Expected non-zero exit for impossible date, got {result.returncode}"
        )
        stderr_lower = result.stderr.lower()
        assert "invalid" in stderr_lower or "date" in stderr_lower, (
            f"Expected error for impossible date, got stderr: {result.stderr[:200]}"
        )

    def test_plan_invalid_type(self):
        """'plan --type nonexistent' caught by argparse choices."""
        result = _run_cli("plan", "--type", "nonexistent")
        assert result.returncode != 0, (
            f"Expected non-zero exit for invalid plan type, got {result.returncode}"
        )
        combined = (result.stdout + result.stderr).lower()
        assert any(
            kw in combined for kw in ["invalid", "choice", "nonexistent", "error"]
        ), f"Expected argparse error for invalid plan type, got: {combined[:300]}"

    def test_plan_default(self):
        """'plan' with default type runs without crash (no module loading)."""
        result = _run_cli("plan", timeout=10)
        assert result.returncode >= 0, (
            f"'plan' crashed: returncode={result.returncode}"
        )

    def test_plan_with_type(self):
        """'plan --type chronological' runs and produces plan output."""
        result = _run_cli("plan", "--type", "chronological", timeout=10)
        assert result.returncode >= 0, (
            f"'plan --type chronological' crashed: returncode={result.returncode}"
        )
        output = result.stdout.lower()
        assert any(
            kw in output for kw in ["chronological", "day", "reading"]
        ), f"Expected plan output, got: {result.stdout[:200]}"

    def test_daily_default_today(self):
        """'daily' without --date runs without crash (no module loading needed
        for date parsing / lectionary structure, though API may fail)."""
        result = _run_cli("daily", timeout=10)
        assert result.returncode >= 0, (
            f"'daily' crashed: returncode={result.returncode}"
        )


# =============================================================================
# Category 2: Module loading edge cases (unit tests)
# =============================================================================


class TestModuleLoading:
    """Tests for module loading, imports, and COMMAND_MAP structure."""

    def test_import_and_main_exists(self):
        """CLI module imports cleanly; main() and build_parser() work."""
        assert callable(main), "main should be callable"
        parser = build_parser()
        assert isinstance(parser, argparse.ArgumentParser), (
            f"build_parser() should return ArgumentParser, got {type(parser)}"
        )

    def test_command_map_all_subcommands(self):
        """COMMAND_MAP contains exactly the 10 expected subcommands."""
        expected = {
            "verse", "study", "interlinear", "daily", "plan",
            "lexicon", "search", "compare", "commentary", "strongs",
        }
        actual = set(COMMAND_MAP.keys())
        assert actual == expected, (
            f"COMMAND_MAP keys mismatch.\n"
            f"  Missing: {expected - actual}\n"
            f"  Extra:   {actual - expected}"
        )
        for name, fn in COMMAND_MAP.items():
            assert callable(fn), (
                f"COMMAND_MAP['{name}'] is not callable: {type(fn)}"
            )

    def test_load_modules_does_not_crash(self):
        """_load_modules() returns without raising (modules pre-warmed by fixture)."""
        # The fixture already called _load_modules().  Call it again to
        # verify idempotency.
        try:
            _load_modules()
        except Exception as e:
            pytest.fail(
                f"_load_modules() raised unexpected: {type(e).__name__}: {e}"
            )

    def test_load_modules_is_idempotent(self):
        """Multiple _load_modules() calls are safe."""
        try:
            for _ in range(5):
                _load_modules()
        except Exception as e:
            pytest.fail(
                f"Repeated _load_modules() raised: {type(e).__name__}: {e}"
            )

    def test_build_parser_subparsers_exist(self):
        """build_parser creates subparsers for all expected commands."""
        parser = build_parser()
        # argparse stores subparsers in _subparsers._group_actions
        subparser_action = None
        for action in parser._actions:
            if isinstance(action, argparse._SubParsersAction):
                subparser_action = action
                break
        assert subparser_action is not None, "No subparsers found in parser"
        sub_names = set(subparser_action.choices.keys())
        expected = set(COMMAND_MAP.keys())
        assert sub_names == expected, (
            f"Subparser names mismatch: missing={expected - sub_names}, "
            f"extra={sub_names - expected}"
        )


# =============================================================================
# Category 3: Reading plan tests (A3-4)  —  pure functions, fast
# =============================================================================


class TestReadingPlans:
    """Unit tests for _get_plan_schedule and _get_plan_name (no I/O)."""

    # ── Chronological ──────────────────────────────────────────────────

    def test_chronological_plan_length(self):
        """Chronological plan has 296 days (hardcoded list)."""
        schedule = _get_plan_schedule("chronological")
        assert isinstance(schedule, list)
        assert len(schedule) == 296, (
            f"Chronological plan has {len(schedule)} days, expected 296"
        )

    def test_chronological_plan_structure(self):
        """Each day of chronological plan is a list of valid 3-tuples."""
        schedule = _get_plan_schedule("chronological")
        for i, day in enumerate(schedule):
            assert isinstance(day, list), f"Day {i}: expected list, got {type(day)}"
            assert len(day) >= 1, f"Day {i}: expected >=1 reading, got {len(day)}"
            for entry in day:
                assert isinstance(entry, tuple) and len(entry) == 3, (
                    f"Day {i}: expected (int,int,int), got {entry}"
                )
                assert all(isinstance(x, int) for x in entry), (
                    f"Day {i}: tuple values should be ints, got {entry}"
                )
                assert entry[1] >= 1 and entry[2] >= entry[1], (
                    f"Day {i}: invalid chapter range {entry[1]}-{entry[2]}"
                )

    def test_chronological_first_day_is_genesis(self):
        """First reading of first day is Genesis (book_id=1)."""
        schedule = _get_plan_schedule("chronological")
        first_day = schedule[0]
        assert first_day[0][0] == 1, (
            f"First day should start with Genesis (1), got book_id={first_day[0][0]}"
        )

    # ── Catholic ───────────────────────────────────────────────────────

    def test_catholic_plan_length(self):
        """Catholic plan has 358 days."""
        schedule = _get_plan_schedule("catholic")
        assert len(schedule) == 358, (
            f"Catholic plan has {len(schedule)} days, expected 358"
        )

    def test_catholic_includes_deuterocanon(self):
        """Catholic plan includes Tobit, Judith, 1-2 Maccabees, Wisdom,
        Sirach, Baruch."""
        schedule = _get_plan_schedule("catholic")
        book_ids: set = set()
        for day in schedule:
            for entry in day:
                book_ids.add(entry[0])

        deutero_expected = {
            68: "Tobit",
            69: "Judith",
            70: "Wisdom",
            71: "Sirach",
            73: "Baruch",
            74: "1 Maccabees",
            75: "2 Maccabees",
        }
        for book_id, name in deutero_expected.items():
            assert book_id in book_ids, (
                f"{name} (id={book_id}) missing from Catholic plan"
            )

    # ── Canonical ──────────────────────────────────────────────────────

    def test_canonical_plan_structure(self):
        """Canonical plan has 300+ days and valid structure."""
        schedule = _get_plan_schedule("canonical")
        assert len(schedule) >= 300, (
            f"Canonical plan has {len(schedule)} days, expected >= 300"
        )
        for day in schedule:
            for entry in day:
                assert isinstance(entry, tuple) and len(entry) == 3

    # ── NT-First ───────────────────────────────────────────────────────

    def test_nt_first_starts_with_nt(self):
        """NT-first plan starts with a New Testament book (id >= 40)."""
        schedule = _get_plan_schedule("nt-first")
        first_book = schedule[0][0][0]
        assert first_book >= 40, (
            f"NT-first plan should start with NT book, got book_id={first_book}"
        )

    def test_nt_first_includes_both_testaments(self):
        """NT-first plan includes both OT (<40) and NT (>=40) books."""
        schedule = _get_plan_schedule("nt-first")
        book_ids = set()
        for day in schedule:
            for entry in day:
                book_ids.add(entry[0])
        has_ot = any(bid < 40 for bid in book_ids)
        has_nt = any(bid >= 40 for bid in book_ids)
        assert has_ot, "NT-first plan should include Old Testament books"
        assert has_nt, "NT-first plan should include New Testament books"

    # ── Psalms-Proverbs ────────────────────────────────────────────────

    def test_psalms_proverbs_structure(self):
        """Psalms-Proverbs plan returns a list of day-tuples starting with Psalms."""
        schedule = _get_plan_schedule("psalms-proverbs")
        assert isinstance(schedule, list)
        assert len(schedule) > 0
        for day in schedule:
            assert isinstance(day, list)
            assert len(day) >= 1
            for entry in day:
                assert isinstance(entry, tuple) and len(entry) == 3
                assert all(isinstance(x, int) for x in entry)

        # First entry of first day should be Psalms (book_id 19)
        assert schedule[0][0][0] == 19, (
            f"First day should start with Psalms (19), got {schedule[0][0][0]}"
        )

    # ── Plan names ─────────────────────────────────────────────────────

    def test_get_plan_name_known(self):
        """_get_plan_name returns expected display names."""
        assert _get_plan_name("chronological") == "Chronological"
        assert "canonical" in _get_plan_name("canonical").lower()
        assert "catholic" in _get_plan_name("catholic").lower()
        assert "new testament" in _get_plan_name("nt-first").lower()
        assert "psalms" in _get_plan_name("psalms-proverbs").lower()

    def test_get_plan_name_unknown_fallback(self):
        """Unknown plan type falls back to 'Chronological'."""
        assert _get_plan_name("bogus") == "Chronological"
        assert _get_plan_name("") == "Chronological"


# =============================================================================
# Category 4: CLI command-function edge cases (direct call, fast after warm-up)
#
# These test the command functions directly (in-process), bypassing argparse
# and subprocess overhead.  Modules are pre-loaded by the module fixture.
# =============================================================================


class TestCommandFunctionEdgeCases:
    """Direct-call tests for command functions (fast — modules pre-warmed)."""

    # ── verse ───────────────────────────────────────────────────────────

    def test_verse_empty_reference(self):
        """cmd_verse with empty reference string should not raise."""
        args = _make_args(reference="", translation="kjv")
        try:
            cmd_verse(args)
        except SystemExit as e:
            # Graceful exit is acceptable (e.g., "could not retrieve")
            assert e.code == 1, (
                f"Expected exit code 1 for empty reference, got {e.code}"
            )
        except Exception as e:
            pytest.fail(
                f"cmd_verse('') raised unexpected: {type(e).__name__}: {e}"
            )

    def test_verse_nonexistent_chapter(self):
        """cmd_verse with Genesis 999:1 should fail gracefully (exit 1 or no crash)."""
        args = _make_args(reference="Genesis 999:1", translation="kjv")
        try:
            cmd_verse(args)
        except SystemExit as e:
            assert e.code == 1, (
                f"Expected exit code 1 for nonexistent chapter, got {e.code}"
            )
        except Exception as e:
            pytest.fail(
                f"cmd_verse('Genesis 999:1') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    def test_verse_john316(self):
        """cmd_verse('John 3:16') produces output or exits gracefully."""
        args = _make_args(reference="John 3:16", translation="kjv")
        try:
            cmd_verse(args)
        except SystemExit as e:
            # If bolls API not reachable, that's fine
            assert e.code == 1, (
                f"cmd_verse('John 3:16') exited with {e.code}"
            )

    # ── lexicon ────────────────────────────────────────────────────────

    def test_lexicon_negative_max_results(self):
        """cmd_lexicon with negative max-results should not crash."""
        args = _make_args(headword="test", max_results=-1)
        try:
            cmd_lexicon(args)
        except SystemExit:
            # No lexicon modules or no specific lexicon → exit 1 is ok
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_lexicon(max_results=-1) raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    def test_lexicon_logos(self):
        """cmd_lexicon('logos') runs without crashing."""
        args = _make_args(headword="logos")
        try:
            cmd_lexicon(args)
        except SystemExit:
            pass  # No modules is acceptable
        except Exception as e:
            pytest.fail(
                f"cmd_lexicon('logos') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    def test_lexicon_unicode_greek(self):
        """cmd_lexicon with Greek Unicode word should not crash."""
        args = _make_args(headword="\u03bb\u03cc\u03b3\u03bf\u03c2")
        try:
            cmd_lexicon(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_lexicon(Greek) raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    def test_lexicon_unicode_hebrew(self):
        """cmd_lexicon with Hebrew Unicode word should not crash."""
        args = _make_args(headword="\u05d0\u05b8\u05d1")
        try:
            cmd_lexicon(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_lexicon(Hebrew) raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── commentary ─────────────────────────────────────────────────────

    def test_commentary_unknown_module(self):
        """cmd_commentary with nonexistent module should exit non-zero or
        print a clear error (no crash)."""
        args = _make_args(reference="John 3:16", module="NONEXISTENT")
        try:
            cmd_commentary(args)
        except SystemExit as e:
            # Should exit 1 (module not found or no modules available)
            assert e.code == 1, (
                f"Expected exit code 1 for unknown module, got {e.code}"
            )
        except Exception as e:
            pytest.fail(
                f"cmd_commentary(NONEXISTENT) raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── study ───────────────────────────────────────────────────────────

    def test_study_john316(self):
        """cmd_study('John 3:16') runs without crash."""
        args = _make_args(reference="John 3:16")
        try:
            cmd_study(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_study('John 3:16') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── strongs ─────────────────────────────────────────────────────────

    def test_strongs_john316(self):
        """cmd_strongs('John 3:16') runs without crash."""
        args = _make_args(reference="John 3:16")
        try:
            cmd_strongs(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_strongs('John 3:16') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── interlinear ─────────────────────────────────────────────────────

    def test_interlinear_john316(self):
        """cmd_interlinear('John 3:16') runs without crash."""
        args = _make_args(reference="John 3:16", translation="kjv")
        try:
            cmd_interlinear(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_interlinear('John 3:16') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── compare ─────────────────────────────────────────────────────────

    def test_compare_john316(self):
        """cmd_compare('John 3:16') runs without crash."""
        args = _make_args(reference="John 3:16", translations="KJV,ESV")
        try:
            cmd_compare(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_compare('John 3:16') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── search ──────────────────────────────────────────────────────────

    def test_search_faith(self):
        """cmd_search('faith') runs without crash."""
        args = _make_args(query="faith", type="all")
        try:
            cmd_search(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_search('faith') raised unexpected: "
                f"{type(e).__name__}: {e}"
            )

    # ── Empty-string edge cases across subcommands ─────────────────────

    def test_search_empty_query(self):
        """cmd_search('') should not crash."""
        args = _make_args(query="", type="all")
        try:
            cmd_search(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_search('') raised unexpected: {type(e).__name__}: {e}"
            )

    def test_strongs_empty_reference(self):
        """cmd_strongs('') should not crash."""
        args = _make_args(reference="")
        try:
            cmd_strongs(args)
        except SystemExit:
            pass
        except Exception as e:
            pytest.fail(
                f"cmd_strongs('') raised unexpected: {type(e).__name__}: {e}"
            )
