"""
tests/track_b/test_story_9_08.py

Story 9.08: EpochTier1Trigger — Autonomous Epistemic Updates Post-Epoch

Black Box Tests (BB):
    BB1  apply() with 3 axioms → Tier1EpochResult.kg_axioms_written == 3
    BB2  apply() always runs regardless of PR (no pr_opened parameter needed)
    BB3  tier1_updates.jsonl contains entry with source="nightly_epoch"

White Box Tests (WB):
    WB1  Both knowledge_writer.write() AND tier1_updater.apply_tier1() are called
    WB2  No .py files are modified (Tier 1 = epistemic only — source check)

Additional tests:
    T01  Tier1EpochResult fields map correctly from WriteResult + Tier1Result
    T02  TIER1_LOG_PATH constant is a string ending in tier1_updates.jsonl
    T03  Log entry timestamp is a valid ISO-8601 UTC string
    T04  Log file is created (parent dirs made) when it does not exist
    T05  Multiple apply() calls each append a separate log entry
    T06  core.epoch __init__ exports EpochTier1Trigger, Tier1EpochResult, TIER1_LOG_PATH
    T07  Log entry epoch_id starts with "epoch_" and contains today's date pattern

ALL external I/O is mocked. tmp_path pytest fixture used for log file paths.
"""

from __future__ import annotations

import dataclasses
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List
from unittest.mock import MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Path bootstrap
# ---------------------------------------------------------------------------

GENESIS_ROOT = "/mnt/e/genesis-system"
if GENESIS_ROOT not in sys.path:
    sys.path.insert(0, GENESIS_ROOT)

# ---------------------------------------------------------------------------
# Imports under test
# ---------------------------------------------------------------------------

from core.epoch.epoch_tier1_trigger import (  # noqa: E402
    EpochTier1Trigger,
    Tier1EpochResult,
    TIER1_LOG_PATH,
)
from core.epoch.axiom_distiller import Axiom  # noqa: E402
from core.evolution.meta_architect import ArchitectureAnalysis  # noqa: E402
from core.epoch.epoch_knowledge_writer import WriteResult  # noqa: E402
from core.evolution.tier1_autonomous_updater import Tier1Result  # noqa: E402

# ---------------------------------------------------------------------------
# Test fixtures and helpers
# ---------------------------------------------------------------------------


def _make_axiom(idx: int = 1, content: str = "Read before build") -> Axiom:
    """Build a single test Axiom."""
    return Axiom(
        id=f"epoch_2026_02_25_{idx:03d}",
        content=content,
        category="operations",
        confidence=0.85,
        source_saga_ids=[f"saga_{idx:03d}"],
    )


def _make_axiom_list(count: int = 3) -> List[Axiom]:
    """Build a list of *count* distinct Axiom objects."""
    return [_make_axiom(idx=i, content=f"Axiom content {i}") for i in range(1, count + 1)]


def _make_analysis(scope: str = "epistemic") -> ArchitectureAnalysis:
    """Build a minimal ArchitectureAnalysis for testing."""
    return ArchitectureAnalysis(
        bottlenecks=[],
        recommended_fixes=[],
        scope=scope,
    )


def _make_mock_knowledge_writer(kg_axioms: int = 3, qdrant_upserts: int = 3) -> MagicMock:
    """Return a MagicMock EpochKnowledgeWriter whose write() returns a WriteResult."""
    mock = MagicMock()
    mock.write.return_value = WriteResult(
        kg_file_path="/mnt/e/genesis-system/KNOWLEDGE_GRAPH/axioms/genesis_evolution_learnings.jsonl",
        qdrant_upserts=qdrant_upserts,
        jsonl_entries=kg_axioms,
    )
    return mock


def _make_mock_tier1_updater(prompts: int = 1, rules: int = 1) -> MagicMock:
    """Return a MagicMock Tier1AutonomousUpdater whose apply_tier1() returns a Tier1Result."""
    mock = MagicMock()
    mock.apply_tier1.return_value = Tier1Result(
        kg_entities_added=0,
        scars_updated=0,
        prompts_updated=prompts,
        rules_updated=rules,
    )
    return mock


def _make_trigger(
    tmp_path: Path,
    kg_axioms: int = 3,
    qdrant_upserts: int = 3,
    prompts: int = 1,
    rules: int = 1,
) -> tuple[EpochTier1Trigger, MagicMock, MagicMock]:
    """Build an EpochTier1Trigger with mocked dependencies and a tmp log path."""
    log_file = str(tmp_path / "tier1_updates.jsonl")
    writer = _make_mock_knowledge_writer(kg_axioms=kg_axioms, qdrant_upserts=qdrant_upserts)
    updater = _make_mock_tier1_updater(prompts=prompts, rules=rules)
    trigger = EpochTier1Trigger(
        knowledge_writer=writer,
        tier1_updater=updater,
        log_path=log_file,
    )
    return trigger, writer, updater


# ===========================================================================
# BB Tests — Black Box
# ===========================================================================


class TestBB1_KgAxiomsWrittenCount:
    """BB1: apply() with 3 axioms → Tier1EpochResult.kg_axioms_written == 3."""

    def test_kg_axioms_written_equals_axiom_count(self, tmp_path: Path):
        axioms = _make_axiom_list(3)
        analysis = _make_analysis()
        trigger, _, _ = _make_trigger(tmp_path, kg_axioms=3)

        result = trigger.apply(axioms, analysis)

        assert result.kg_axioms_written == 3, (
            f"Expected kg_axioms_written=3, got {result.kg_axioms_written}"
        )

    def test_kg_axioms_written_zero_for_empty_list(self, tmp_path: Path):
        """Empty axiom list: writer returns 0, result reflects 0."""
        trigger, _, _ = _make_trigger(tmp_path, kg_axioms=0, qdrant_upserts=0)

        result = trigger.apply([], _make_analysis())

        assert result.kg_axioms_written == 0

    def test_qdrant_scars_updated_propagated(self, tmp_path: Path):
        """qdrant_scars_updated comes from WriteResult.qdrant_upserts."""
        trigger, _, _ = _make_trigger(tmp_path, kg_axioms=2, qdrant_upserts=2)

        result = trigger.apply(_make_axiom_list(2), _make_analysis())

        assert result.qdrant_scars_updated == 2


class TestBB2_AlwaysRuns:
    """BB2: apply() always runs — no PR gate, no conditional skip."""

    def test_apply_runs_without_any_pr_argument(self, tmp_path: Path):
        """apply() signature takes only (axioms, analysis) — no pr_opened param."""
        trigger, writer, updater = _make_trigger(tmp_path)

        # Must not raise; signature must not require a pr_opened parameter
        result = trigger.apply(_make_axiom_list(3), _make_analysis())

        assert isinstance(result, Tier1EpochResult)

    def test_apply_runs_with_empty_analysis(self, tmp_path: Path):
        """Even with a minimal empty ArchitectureAnalysis, apply() completes."""
        empty_analysis = ArchitectureAnalysis(bottlenecks=[], recommended_fixes=[], scope="epistemic")
        trigger, writer, updater = _make_trigger(tmp_path, prompts=0, rules=0)

        result = trigger.apply([], empty_analysis)

        # writer and updater must still have been called
        writer.write.assert_called_once()
        updater.apply_tier1.assert_called_once()
        assert isinstance(result, Tier1EpochResult)


class TestBB3_LogEntrySourceNightlyEpoch:
    """BB3: tier1_updates.jsonl contains an entry with source='nightly_epoch'."""

    def test_log_entry_has_source_nightly_epoch(self, tmp_path: Path):
        trigger, _, _ = _make_trigger(tmp_path)
        trigger.apply(_make_axiom_list(3), _make_analysis())

        log_file = Path(trigger.log_path)
        assert log_file.exists(), f"Log file not found at {log_file}"

        lines = [ln.strip() for ln in log_file.read_text().splitlines() if ln.strip()]
        assert len(lines) >= 1, "No log entries written"

        entry = json.loads(lines[0])
        assert entry.get("source") == "nightly_epoch", (
            f"Expected source='nightly_epoch', got {entry.get('source')!r}"
        )

    def test_log_entry_contains_all_count_fields(self, tmp_path: Path):
        """Each log entry has the four count fields."""
        trigger, _, _ = _make_trigger(tmp_path, kg_axioms=3, qdrant_upserts=3, prompts=1, rules=1)
        trigger.apply(_make_axiom_list(3), _make_analysis())

        log_file = Path(trigger.log_path)
        entry = json.loads(log_file.read_text().strip().splitlines()[-1])

        assert "kg_axioms_written" in entry
        assert "qdrant_scars_updated" in entry
        assert "prompt_templates_updated" in entry
        assert "rules_appended" in entry


# ===========================================================================
# WB Tests — White Box
# ===========================================================================


class TestWB1_BothDependenciesCalled:
    """WB1: Both knowledge_writer.write() AND tier1_updater.apply_tier1() are called."""

    def test_knowledge_writer_write_is_called(self, tmp_path: Path):
        axioms = _make_axiom_list(3)
        analysis = _make_analysis()
        trigger, writer, updater = _make_trigger(tmp_path)

        trigger.apply(axioms, analysis)

        writer.write.assert_called_once()

    def test_tier1_updater_apply_tier1_is_called(self, tmp_path: Path):
        axioms = _make_axiom_list(3)
        analysis = _make_analysis()
        trigger, writer, updater = _make_trigger(tmp_path)

        trigger.apply(axioms, analysis)

        updater.apply_tier1.assert_called_once()

    def test_both_called_in_same_apply_invocation(self, tmp_path: Path):
        """Both dependencies are called in a single apply() — not one or the other."""
        axioms = _make_axiom_list(2)
        analysis = _make_analysis()
        trigger, writer, updater = _make_trigger(tmp_path)

        trigger.apply(axioms, analysis)

        assert writer.write.call_count == 1, (
            f"knowledge_writer.write() called {writer.write.call_count} times, expected 1"
        )
        assert updater.apply_tier1.call_count == 1, (
            f"tier1_updater.apply_tier1() called {updater.apply_tier1.call_count} times, expected 1"
        )

    def test_axioms_passed_to_knowledge_writer(self, tmp_path: Path):
        """Axioms list is forwarded to knowledge_writer.write() as first positional arg."""
        axioms = _make_axiom_list(3)
        analysis = _make_analysis()
        trigger, writer, _ = _make_trigger(tmp_path)

        trigger.apply(axioms, analysis)

        call_args = writer.write.call_args
        # First positional arg (or keyword 'axioms') is the axioms list
        passed_axioms = call_args.args[0] if call_args.args else call_args.kwargs.get("axioms")
        assert passed_axioms == axioms, (
            f"Expected axioms list passed to knowledge_writer.write(), "
            f"got {passed_axioms!r}"
        )

    def test_analysis_passed_to_tier1_updater(self, tmp_path: Path):
        """ArchitectureAnalysis is forwarded to tier1_updater.apply_tier1()."""
        axioms = _make_axiom_list(1)
        analysis = _make_analysis(scope="epistemic")
        trigger, _, updater = _make_trigger(tmp_path)

        trigger.apply(axioms, analysis)

        call_args = updater.apply_tier1.call_args
        passed_analysis = call_args.args[0] if call_args.args else call_args.kwargs.get("analysis")
        assert passed_analysis is analysis, (
            "ArchitectureAnalysis object was not forwarded to tier1_updater.apply_tier1()"
        )


class TestWB2_NoPyFilesModified:
    """WB2: No .py files are modified — verify via source code inspection."""

    def test_no_open_calls_to_py_files(self):
        """Source of epoch_tier1_trigger.py must not contain open() calls targeting .py files."""
        import core.epoch.epoch_tier1_trigger as mod
        source_path = mod.__file__
        with open(source_path, encoding="utf-8") as fh:
            source = fh.read()

        # Any open() call that explicitly names a .py extension is forbidden at Tier 1
        # We look for patterns like open("something.py") or open(... + ".py")
        # The source itself is allowed to import .py modules — just not to open them for writing.
        # Check that there is no open() call with a ".py" path string literal.
        import re
        py_open_pattern = re.compile(r'open\s*\([^)]*\.py[^)]*\)')
        matches = py_open_pattern.findall(source)
        assert not matches, (
            f"epoch_tier1_trigger.py contains open() calls targeting .py files: {matches}"
        )

    def test_no_write_to_py_extension(self):
        """No file write operation should target a .py path."""
        import core.epoch.epoch_tier1_trigger as mod
        source_path = mod.__file__
        with open(source_path, encoding="utf-8") as fh:
            source = fh.read()

        # Check that ".py" does not appear as a file extension in any write context
        # (The import statements reference .py modules but those are read-only by Python itself)
        # We scan for explicit .py string usage beyond import lines
        lines_with_py = [
            line for line in source.splitlines()
            if ".py" in line and not line.strip().startswith(("#", "from ", "import "))
        ]
        py_write_lines = [line for line in lines_with_py if "open(" in line]
        assert not py_write_lines, (
            f"Found .py file references in open() context: {py_write_lines}"
        )


# ===========================================================================
# Additional Tests
# ===========================================================================


def test_t01_result_fields_map_from_write_and_tier1_results(tmp_path: Path):
    """T01: Tier1EpochResult fields correctly map from WriteResult and Tier1Result."""
    trigger, _, _ = _make_trigger(
        tmp_path,
        kg_axioms=5,
        qdrant_upserts=4,
        prompts=2,
        rules=3,
    )
    result = trigger.apply(_make_axiom_list(5), _make_analysis())

    assert result.kg_axioms_written == 5
    assert result.qdrant_scars_updated == 4
    assert result.prompt_templates_updated == 2
    assert result.rules_appended == 3


def test_t02_tier1_log_path_constant(tmp_path: Path):
    """T02: TIER1_LOG_PATH is a string ending in 'tier1_updates.jsonl'."""
    assert isinstance(TIER1_LOG_PATH, str), "TIER1_LOG_PATH must be a string"
    assert TIER1_LOG_PATH.endswith("tier1_updates.jsonl"), (
        f"TIER1_LOG_PATH should end with 'tier1_updates.jsonl', got: {TIER1_LOG_PATH!r}"
    )


def test_t03_log_timestamp_is_iso8601(tmp_path: Path):
    """T03: Log entry timestamp is a parseable ISO-8601 UTC string."""
    trigger, _, _ = _make_trigger(tmp_path)
    trigger.apply(_make_axiom_list(2), _make_analysis())

    log_file = Path(trigger.log_path)
    entry = json.loads(log_file.read_text().strip().splitlines()[-1])

    ts = entry.get("timestamp", "")
    assert ts, "timestamp field must not be empty"
    # Must be parseable as datetime (raises ValueError otherwise)
    dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
    assert dt.tzinfo is not None, "timestamp must include timezone info (UTC)"


def test_t04_log_file_parent_dirs_created(tmp_path: Path):
    """T04: Log file parent dirs are created automatically if they don't exist."""
    nested_log = str(tmp_path / "deep" / "nested" / "tier1_updates.jsonl")
    writer = _make_mock_knowledge_writer(kg_axioms=1)
    updater = _make_mock_tier1_updater(prompts=0, rules=0)
    trigger = EpochTier1Trigger(
        knowledge_writer=writer,
        tier1_updater=updater,
        log_path=nested_log,
    )

    # Parent dirs do NOT exist yet
    assert not os.path.exists(os.path.dirname(nested_log))

    trigger.apply(_make_axiom_list(1), _make_analysis())

    assert os.path.isfile(nested_log), (
        f"Log file should have been created at {nested_log}"
    )


def test_t05_multiple_apply_calls_each_append_log_entry(tmp_path: Path):
    """T05: Each apply() call appends a separate log entry (not overwrite)."""
    trigger, _, _ = _make_trigger(tmp_path)

    trigger.apply(_make_axiom_list(1), _make_analysis())
    trigger.apply(_make_axiom_list(2), _make_analysis())
    trigger.apply(_make_axiom_list(3), _make_analysis())

    log_file = Path(trigger.log_path)
    lines = [ln.strip() for ln in log_file.read_text().splitlines() if ln.strip()]
    assert len(lines) == 3, (
        f"Expected 3 log entries after 3 apply() calls, got {len(lines)}"
    )
    # Each must be valid JSON with source="nightly_epoch"
    for line in lines:
        entry = json.loads(line)
        assert entry["source"] == "nightly_epoch"


def test_t06_package_init_exports(tmp_path: Path):
    """T06: core.epoch __init__ exports EpochTier1Trigger, Tier1EpochResult, TIER1_LOG_PATH."""
    from core.epoch import (  # noqa: F401
        EpochTier1Trigger as ETT,
        Tier1EpochResult as TER,
        TIER1_LOG_PATH as TLP,
    )

    assert ETT is EpochTier1Trigger, "EpochTier1Trigger not re-exported from core.epoch"
    assert TER is Tier1EpochResult, "Tier1EpochResult not re-exported from core.epoch"
    assert TLP == TIER1_LOG_PATH, "TIER1_LOG_PATH not re-exported from core.epoch"


def test_t07_log_epoch_id_format(tmp_path: Path):
    """T07: Log entry epoch_id starts with 'epoch_' and contains a date pattern."""
    import re
    trigger, _, _ = _make_trigger(tmp_path)
    trigger.apply(_make_axiom_list(1), _make_analysis())

    log_file = Path(trigger.log_path)
    entry = json.loads(log_file.read_text().strip())

    epoch_id = entry.get("epoch_id", "")
    assert epoch_id.startswith("epoch_"), (
        f"epoch_id should start with 'epoch_', got: {epoch_id!r}"
    )
    # Must contain YYYY_MM_DD pattern
    date_pattern = re.compile(r"\d{4}_\d{2}_\d{2}")
    assert date_pattern.search(epoch_id), (
        f"epoch_id should contain YYYY_MM_DD date pattern, got: {epoch_id!r}"
    )


def test_tier1_epoch_result_is_dataclass():
    """Tier1EpochResult must be a proper dataclass with expected fields."""
    assert dataclasses.is_dataclass(Tier1EpochResult)
    field_names = {f.name for f in dataclasses.fields(Tier1EpochResult)}
    expected = {
        "kg_axioms_written",
        "qdrant_scars_updated",
        "prompt_templates_updated",
        "rules_appended",
    }
    assert expected == field_names, (
        f"Tier1EpochResult fields mismatch. Expected: {expected}, got: {field_names}"
    )


def test_log_oserror_does_not_surface(tmp_path: Path):
    """OSError on log write must be caught and not propagate to caller."""
    writer = _make_mock_knowledge_writer(kg_axioms=1)
    updater = _make_mock_tier1_updater()
    trigger = EpochTier1Trigger(
        knowledge_writer=writer,
        tier1_updater=updater,
        log_path="/mnt/e/this/path/does/not/exist/readonly.jsonl",
    )

    # Patch Path.mkdir to raise OSError (simulates unwritable filesystem)
    with patch.object(Path, "mkdir", side_effect=OSError("Permission denied")):
        # Must not raise — error is swallowed
        result = trigger.apply(_make_axiom_list(1), _make_analysis())

    # Result should still be populated from mock returns
    assert result.kg_axioms_written == 1


# ===========================================================================
# Standalone runner
# ===========================================================================

if __name__ == "__main__":
    import traceback
    import tempfile

    def _tmp() -> Path:
        return Path(tempfile.mkdtemp())

    tests = [
        ("BB1: kg_axioms_written==3 for 3 axioms", lambda: TestBB1_KgAxiomsWrittenCount().test_kg_axioms_written_equals_axiom_count(_tmp())),
        ("BB1: kg_axioms_written==0 for empty list", lambda: TestBB1_KgAxiomsWrittenCount().test_kg_axioms_written_zero_for_empty_list(_tmp())),
        ("BB1: qdrant_scars_updated propagated", lambda: TestBB1_KgAxiomsWrittenCount().test_qdrant_scars_updated_propagated(_tmp())),
        ("BB2: apply() runs without pr argument", lambda: TestBB2_AlwaysRuns().test_apply_runs_without_any_pr_argument(_tmp())),
        ("BB2: apply() runs with empty analysis", lambda: TestBB2_AlwaysRuns().test_apply_runs_with_empty_analysis(_tmp())),
        ("BB3: log entry has source='nightly_epoch'", lambda: TestBB3_LogEntrySourceNightlyEpoch().test_log_entry_has_source_nightly_epoch(_tmp())),
        ("BB3: log entry has all count fields", lambda: TestBB3_LogEntrySourceNightlyEpoch().test_log_entry_contains_all_count_fields(_tmp())),
        ("WB1: knowledge_writer.write() called", lambda: TestWB1_BothDependenciesCalled().test_knowledge_writer_write_is_called(_tmp())),
        ("WB1: tier1_updater.apply_tier1() called", lambda: TestWB1_BothDependenciesCalled().test_tier1_updater_apply_tier1_is_called(_tmp())),
        ("WB1: both called in same apply()", lambda: TestWB1_BothDependenciesCalled().test_both_called_in_same_apply_invocation(_tmp())),
        ("WB1: axioms passed to knowledge_writer", lambda: TestWB1_BothDependenciesCalled().test_axioms_passed_to_knowledge_writer(_tmp())),
        ("WB1: analysis passed to tier1_updater", lambda: TestWB1_BothDependenciesCalled().test_analysis_passed_to_tier1_updater(_tmp())),
        ("WB2: no open() to .py files", TestWB2_NoPyFilesModified().test_no_open_calls_to_py_files),
        ("WB2: no .py file writes", TestWB2_NoPyFilesModified().test_no_write_to_py_extension),
        ("T01: result fields map correctly", lambda: test_t01_result_fields_map_from_write_and_tier1_results(_tmp())),
        ("T02: TIER1_LOG_PATH constant", lambda: test_t02_tier1_log_path_constant(_tmp())),
        ("T03: log timestamp is ISO-8601", lambda: test_t03_log_timestamp_is_iso8601(_tmp())),
        ("T04: log parent dirs created", lambda: test_t04_log_file_parent_dirs_created(_tmp())),
        ("T05: multiple calls append entries", lambda: test_t05_multiple_apply_calls_each_append_log_entry(_tmp())),
        ("T06: __init__ exports correct", lambda: test_t06_package_init_exports(_tmp())),
        ("T07: epoch_id format correct", lambda: test_t07_log_epoch_id_format(_tmp())),
        ("EDGE: Tier1EpochResult is dataclass", test_tier1_epoch_result_is_dataclass),
        ("EDGE: OSError on log write does not surface", lambda: test_log_oserror_does_not_surface(_tmp())),
    ]

    passed = 0
    total = len(tests)
    for name, fn in tests:
        try:
            fn()
            print(f"  [PASS] {name}")
            passed += 1
        except Exception as exc:  # noqa: BLE001
            print(f"  [FAIL] {name}: {exc}")
            traceback.print_exc()

    print(f"\n{passed}/{total} tests passed")
    if passed == total:
        print("ALL TESTS PASSED -- Story 9.08 (Track B)")
    else:
        sys.exit(1)
