"""
tests/track_b/test_story_9_02.py

Story 9.02: EpochRunner — Full Epoch Orchestrator

Black Box Tests (BB):
    BB1  Successful epoch with ontological proposal + arena pass
         → all phases (including 'pr_create') in phases_completed, pr_url set
    BB2  Successful epoch with epistemic scope
         → phases do NOT include 'code_propose'/'shadow_arena'/'pr_create';
           tier1_update IS present; pr_url=None
    BB3  run_epoch_safe() with lock already held → returns None immediately
    BB4  Epoch log entry written with correct epoch_id and status

White Box Tests (WB):
    WB1  Phase execution is sequential (mock call order verification)
    WB2  Lock acquired before run_epoch, released in finally
    WB3  asyncio.wait_for with 7200s timeout used
    WB4  Phase failure mid-run → partial phases_completed, success not blocking remaining phases

ALL external I/O is mocked — zero live Redis/Postgres/Gemini/Qdrant.
"""

from __future__ import annotations

import asyncio
import json
import os
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, call, patch

import pytest

# ---------------------------------------------------------------------------
# Path setup
# ---------------------------------------------------------------------------

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_runner import EpochRunner, EPOCH_LOG_PATH  # noqa: E402
from core.epoch.epoch_report_generator import EpochResult  # noqa: E402
from core.epoch.axiom_distiller import Axiom, DistillationResult  # noqa: E402
from core.epoch.epoch_knowledge_writer import WriteResult  # noqa: E402
from core.epoch.epoch_tier1_trigger import Tier1EpochResult  # noqa: E402
from core.evolution.meta_architect import ArchitectureAnalysis, Bottleneck  # noqa: E402
from core.evolution.code_proposer import CodeProposal  # noqa: E402
from core.evolution.shadow_arena import ArenaResult  # noqa: E402
from core.evolution.gitops_pr_creator import PRResult  # noqa: E402
from core.evolution.tier1_autonomous_updater import Tier1Result  # noqa: E402
from core.epoch.epoch_report_generator import EpochReport  # noqa: E402
from core.evolution.scar_aggregator import ScarReport  # noqa: E402
from core.epoch.conversation_aggregator import WeeklyConversationSummary  # noqa: E402

# ---------------------------------------------------------------------------
# Helper factories
# ---------------------------------------------------------------------------


def _make_axiom(idx: int = 1) -> Axiom:
    return Axiom(
        id=f"epoch_2026_02_25_{idx:03d}",
        content=f"Axiom content {idx}",
        category="operations",
        confidence=0.85,
        source_saga_ids=[f"saga_{idx:03d}"],
    )


def _make_axioms(n: int = 3) -> list[Axiom]:
    return [_make_axiom(i) for i in range(1, n + 1)]


def _make_distillation_result(n_axioms: int = 3) -> DistillationResult:
    return DistillationResult(
        axioms=_make_axioms(n_axioms),
        week_summary="Genesis ran 12 sessions. Three failures resolved.",
    )


def _make_weekly_summary() -> WeeklyConversationSummary:
    now = datetime.now(timezone.utc)
    return WeeklyConversationSummary(
        total_sessions=10,
        total_tasks=50,
        failed_tasks=3,
        conversation_snippets=["event1", "event2"],
        period_start=now,
        period_end=now,
    )


def _make_scar_report() -> ScarReport:
    return ScarReport(total_scars=5, clusters=[], new_since_last_epoch=2)


def _make_analysis(scope: str = "epistemic") -> ArchitectureAnalysis:
    bottlenecks = []
    if scope == "ontological":
        bottlenecks = [
            Bottleneck(
                description="function missing in router.py",
                frequency=3,
                affected_saga_ids=["s1", "s2"],
                scar_ids=["scar_001"],
            )
        ]
    return ArchitectureAnalysis(
        bottlenecks=bottlenecks,
        recommended_fixes=[],
        scope=scope,
    )


def _make_proposal() -> CodeProposal:
    return CodeProposal(
        file_path="core/interceptors/fix_router.py",
        code_content="class FixRouter(BaseInterceptor): pass",
        test_file_path="tests/interceptors/test_fix_router.py",
        test_content="def test_fix(): assert True",
        config_changes={},
    )


def _make_arena_result(passed: bool = True, pass_rate: float = 0.9) -> ArenaResult:
    return ArenaResult(
        pass_rate=pass_rate,
        axiom_violations=[],
        improved_metrics={"delta": 0.2},
        ready_for_pr=passed,
    )


def _make_pr_result(
    pr_url: str = "https://github.com/genesis/repo/pull/42",
) -> PRResult:
    return PRResult(
        pr_url=pr_url,
        branch_name="genesis-auto-refactor-epoch_2026_02_25",
        pr_number=42,
    )


def _make_tier1_epoch_result() -> Tier1EpochResult:
    return Tier1EpochResult(
        kg_axioms_written=3,
        qdrant_scars_updated=3,
        prompt_templates_updated=1,
        rules_appended=1,
    )


def _make_epoch_report(epoch_id: str = "epoch_2026_02_25") -> EpochReport:
    return EpochReport(
        markdown_content="# Genesis Nightly Epoch",
        file_path=f"/tmp/epoch_{epoch_id}.md",
        epoch_id=epoch_id,
    )


def _make_write_result() -> WriteResult:
    return WriteResult(
        kg_file_path="/mnt/e/genesis-system/KNOWLEDGE_GRAPH/axioms/genesis_evolution_learnings.jsonl",
        qdrant_upserts=3,
        jsonl_entries=3,
    )


# ---------------------------------------------------------------------------
# Core fixture builder
# ---------------------------------------------------------------------------


def _build_runner(
    *,
    tmp_path: Path,
    scope: str = "epistemic",
    arena_passed: bool = True,
    lock_acquired: bool = True,
) -> tuple[EpochRunner, dict]:
    """
    Build an EpochRunner with all mocked dependencies.

    Returns (runner, mocks_dict) for call verification.
    """
    mocks = {}

    # RedisEpochLock
    lock = MagicMock()
    lock.acquire.return_value = lock_acquired
    lock.release.return_value = None
    mocks["lock"] = lock

    # ConversationAggregator
    aggregator = MagicMock()
    aggregator.aggregate.return_value = _make_weekly_summary()
    mocks["aggregator"] = aggregator

    # ScarAggregator
    scar_aggregator = MagicMock()
    scar_aggregator.aggregate.return_value = _make_scar_report()
    mocks["scar_aggregator"] = scar_aggregator

    # AxiomDistiller
    distiller = MagicMock()
    distiller.distill.return_value = _make_distillation_result(n_axioms=3)
    mocks["distiller"] = distiller

    # EpochKnowledgeWriter
    knowledge_writer = MagicMock()
    knowledge_writer.write.return_value = _make_write_result()
    mocks["knowledge_writer"] = knowledge_writer

    # MetaArchitect
    meta_architect = MagicMock()
    meta_architect.analyze.return_value = _make_analysis(scope=scope)
    mocks["meta_architect"] = meta_architect

    # CodeProposer
    code_proposer = MagicMock()
    code_proposer.propose.return_value = _make_proposal()
    mocks["code_proposer"] = code_proposer

    # ShadowArena
    shadow_arena = MagicMock()
    shadow_arena.evaluate_proposal.return_value = _make_arena_result(
        passed=arena_passed,
        pass_rate=0.9 if arena_passed else 0.5,
    )
    mocks["shadow_arena"] = shadow_arena

    # GitOpsPRCreator
    pr_creator = MagicMock()
    pr_creator.create_pr.return_value = _make_pr_result()
    mocks["pr_creator"] = pr_creator

    # EpochTier1Trigger
    tier1_trigger = MagicMock()
    tier1_trigger.apply.return_value = _make_tier1_epoch_result()
    mocks["tier1_trigger"] = tier1_trigger

    # EpochReportGenerator
    report_generator = MagicMock()
    report_generator.generate.return_value = _make_epoch_report()
    mocks["report_generator"] = report_generator

    epoch_log_path = str(tmp_path / "epoch_log.jsonl")

    runner = EpochRunner(
        lock=lock,
        aggregator=aggregator,
        scar_aggregator=scar_aggregator,
        distiller=distiller,
        knowledge_writer=knowledge_writer,
        meta_architect=meta_architect,
        code_proposer=code_proposer,
        shadow_arena=shadow_arena,
        pr_creator=pr_creator,
        tier1_trigger=tier1_trigger,
        report_generator=report_generator,
        epoch_log_path=epoch_log_path,
    )

    return runner, mocks


def _run(coro):
    """Helper to run an async coroutine synchronously in tests."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ===========================================================================
# BB Tests — Black Box
# ===========================================================================


class TestBB1_OntologicalEpochWithPR:
    """BB1: ontological scope + arena passes → pr_create in phases, pr_url set."""

    def test_pr_url_is_set(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert result.pr_url is not None, "pr_url should be set when arena passes"
        assert "github.com" in result.pr_url or result.pr_url.startswith("https://"), (
            f"pr_url looks invalid: {result.pr_url!r}"
        )

    def test_pr_create_in_phases_completed(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "pr_create" in result.phases_completed, (
            f"'pr_create' should be in phases_completed: {result.phases_completed}"
        )

    def test_code_propose_in_phases_completed(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "code_propose" in result.phases_completed, (
            f"'code_propose' should be in phases for ontological: {result.phases_completed}"
        )

    def test_shadow_arena_in_phases_completed(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "shadow_arena" in result.phases_completed, (
            f"'shadow_arena' should be in phases for ontological: {result.phases_completed}"
        )

    def test_core_phases_all_present(self, tmp_path: Path):
        """Phases 1-5 are always present regardless of scope."""
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        for phase in [
            "conversation_aggregate",
            "scar_aggregate",
            "axiom_distill",
            "knowledge_write",
            "meta_architect",
        ]:
            assert phase in result.phases_completed, (
                f"Core phase '{phase}' missing from {result.phases_completed}"
            )

    def test_shadow_pass_rate_set(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert result.shadow_pass_rate is not None, "shadow_pass_rate should be set for ontological"
        assert 0.0 <= result.shadow_pass_rate <= 1.0


class TestBB2_EpistemicEpochNoCodeProposal:
    """BB2: epistemic scope → no code_propose/shadow_arena/pr_create; tier1_update present."""

    def test_pr_url_is_none(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert result.pr_url is None, (
            f"pr_url should be None for epistemic scope, got: {result.pr_url!r}"
        )

    def test_no_code_propose_in_phases(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "code_propose" not in result.phases_completed, (
            f"'code_propose' should NOT be present for epistemic: {result.phases_completed}"
        )

    def test_no_shadow_arena_in_phases(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "shadow_arena" not in result.phases_completed, (
            f"'shadow_arena' should NOT be present for epistemic: {result.phases_completed}"
        )

    def test_no_pr_create_in_phases(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "pr_create" not in result.phases_completed, (
            f"'pr_create' should NOT be present for epistemic: {result.phases_completed}"
        )

    def test_tier1_update_in_phases(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "tier1_update" in result.phases_completed, (
            f"'tier1_update' should be in phases: {result.phases_completed}"
        )

    def test_core_phases_present(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        result = _run(runner.run_epoch("epoch_2026_02_25"))

        for phase in [
            "conversation_aggregate",
            "scar_aggregate",
            "axiom_distill",
            "knowledge_write",
            "meta_architect",
            "tier1_update",
        ]:
            assert phase in result.phases_completed, (
                f"Phase '{phase}' missing from epistemic run: {result.phases_completed}"
            )

    def test_code_proposer_not_called(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        mocks["code_proposer"].propose.assert_not_called()

    def test_shadow_arena_not_called(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        mocks["shadow_arena"].evaluate_proposal.assert_not_called()

    def test_pr_creator_not_called(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        mocks["pr_creator"].create_pr.assert_not_called()


class TestBB3_RunEpochSafeLockHeld:
    """BB3: run_epoch_safe() with lock held → returns None immediately."""

    def test_returns_none_when_lock_held(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=False)
        result = _run(runner.run_epoch_safe())

        assert result is None, f"Expected None when lock is held, got: {result!r}"

    def test_no_epoch_runs_when_lock_held(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=False)
        _run(runner.run_epoch_safe())

        # None of the execution phases should have been called
        mocks["aggregator"].aggregate.assert_not_called()
        mocks["distiller"].distill.assert_not_called()

    def test_returns_result_when_lock_acquired(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")
        result = _run(runner.run_epoch_safe())

        assert result is not None, "Expected an EpochResult when lock is acquired"
        assert isinstance(result, EpochResult), f"Expected EpochResult, got {type(result)}"

    def test_lock_released_after_successful_run(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")
        _run(runner.run_epoch_safe())

        mocks["lock"].release.assert_called_once()

    def test_lock_released_even_on_error(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")
        # Make aggregator raise an unexpected exception
        mocks["aggregator"].aggregate.side_effect = RuntimeError("DB crashed")

        # run_epoch_safe should return a partial result (not re-raise), lock still released
        result = _run(runner.run_epoch_safe())

        # Lock must be released
        mocks["lock"].release.assert_called_once()


class TestBB4_EpochLogWritten:
    """BB4: Epoch log entry written to EPOCH_LOG_PATH with correct epoch_id."""

    def test_log_file_created(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        log_file = Path(runner.epoch_log_path)
        assert log_file.exists(), f"Epoch log not found at {log_file}"

    def test_log_entry_has_correct_epoch_id(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        log_file = Path(runner.epoch_log_path)
        lines = [ln.strip() for ln in log_file.read_text().splitlines() if ln.strip()]
        assert len(lines) >= 1, "No epoch log entries written"

        entry = json.loads(lines[0])
        assert entry.get("epoch_id") == "epoch_2026_02_25", (
            f"epoch_id mismatch: {entry.get('epoch_id')!r}"
        )

    def test_log_entry_has_phases_completed(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        log_file = Path(runner.epoch_log_path)
        entry = json.loads(log_file.read_text().strip().splitlines()[-1])

        assert "phases_completed" in entry
        assert isinstance(entry["phases_completed"], list)

    def test_log_entry_has_duration(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        log_file = Path(runner.epoch_log_path)
        entry = json.loads(log_file.read_text().strip().splitlines()[-1])

        assert "duration_seconds" in entry
        assert isinstance(entry["duration_seconds"], (int, float))
        assert entry["duration_seconds"] >= 0.0

    def test_log_entry_has_timestamp(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        log_file = Path(runner.epoch_log_path)
        entry = json.loads(log_file.read_text().strip().splitlines()[-1])

        ts = entry.get("timestamp", "")
        assert ts, "timestamp field must not be empty"
        dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
        assert dt.tzinfo is not None, "timestamp must include timezone info"

    def test_log_entry_axioms_count(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        _run(runner.run_epoch("epoch_2026_02_25"))

        log_file = Path(runner.epoch_log_path)
        entry = json.loads(log_file.read_text().strip().splitlines()[-1])

        # 3 axioms were returned by the mock distiller
        assert entry.get("axioms_count") == 3, (
            f"Expected axioms_count=3, got {entry.get('axioms_count')!r}"
        )


# ===========================================================================
# WB Tests — White Box
# ===========================================================================


class TestWB1_SequentialExecution:
    """WB1: Phase execution is sequential (mock call order verification)."""

    def test_aggregator_called_before_distiller(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")

        call_order: list[str] = []
        mocks["aggregator"].aggregate.side_effect = (
            lambda **kw: call_order.append("aggregator") or _make_weekly_summary()
        )
        mocks["distiller"].distill.side_effect = (
            lambda *a, **kw: call_order.append("distiller") or _make_distillation_result()
        )

        _run(runner.run_epoch("epoch_2026_02_25"))

        assert call_order.index("aggregator") < call_order.index("distiller"), (
            f"aggregator must be called before distiller; order: {call_order}"
        )

    def test_distiller_called_before_knowledge_writer(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")

        call_order: list[str] = []
        mocks["distiller"].distill.side_effect = (
            lambda *a, **kw: call_order.append("distiller") or _make_distillation_result()
        )
        mocks["knowledge_writer"].write.side_effect = (
            lambda *a, **kw: call_order.append("knowledge_writer") or _make_write_result()
        )

        _run(runner.run_epoch("epoch_2026_02_25"))

        assert call_order.index("distiller") < call_order.index("knowledge_writer"), (
            f"distiller must precede knowledge_writer; order: {call_order}"
        )

    def test_meta_architect_called_after_knowledge_writer(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")

        call_order: list[str] = []
        mocks["knowledge_writer"].write.side_effect = (
            lambda *a, **kw: call_order.append("knowledge_writer") or _make_write_result()
        )
        mocks["meta_architect"].analyze.side_effect = (
            lambda **kw: call_order.append("meta_architect") or _make_analysis()
        )

        _run(runner.run_epoch("epoch_2026_02_25"))

        assert call_order.index("knowledge_writer") < call_order.index("meta_architect"), (
            f"knowledge_writer must precede meta_architect; order: {call_order}"
        )

    def test_tier1_trigger_called_after_meta_architect(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")

        call_order: list[str] = []
        mocks["meta_architect"].analyze.side_effect = (
            lambda **kw: call_order.append("meta_architect") or _make_analysis()
        )
        mocks["tier1_trigger"].apply.side_effect = (
            lambda *a, **kw: call_order.append("tier1_trigger") or _make_tier1_epoch_result()
        )

        _run(runner.run_epoch("epoch_2026_02_25"))

        assert call_order.index("meta_architect") < call_order.index("tier1_trigger"), (
            f"meta_architect must precede tier1_trigger; order: {call_order}"
        )


class TestWB2_LockAcquireAndRelease:
    """WB2: Lock acquired before run_epoch; released in finally block."""

    def test_lock_acquired_before_aggregator(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")

        call_order: list[str] = []
        mocks["lock"].acquire.side_effect = (
            lambda epoch_id: call_order.append("lock.acquire") or True
        )
        mocks["aggregator"].aggregate.side_effect = (
            lambda **kw: call_order.append("aggregator") or _make_weekly_summary()
        )

        _run(runner.run_epoch_safe())

        assert call_order.index("lock.acquire") < call_order.index("aggregator"), (
            f"lock.acquire must precede aggregator; order: {call_order}"
        )

    def test_lock_released_after_epoch(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")
        _run(runner.run_epoch_safe())

        mocks["lock"].release.assert_called_once()

    def test_lock_released_on_phase_exception(self, tmp_path: Path):
        """Lock must be released even if a phase raises an uncaught exception."""
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")
        # Make the distiller raise a non-caught exception type
        mocks["distiller"].distill.side_effect = MemoryError("OOM")

        # run_epoch_safe should NOT propagate the MemoryError — it returns a result
        result = _run(runner.run_epoch_safe())

        # Lock must still be released
        mocks["lock"].release.assert_called_once()


class TestWB3_AsyncTimeoutUsed:
    """WB3: asyncio.wait_for with 7200s timeout is used in run_epoch_safe."""

    def test_timeout_constant_is_7200(self):
        from core.epoch.epoch_runner import _EPOCH_TIMEOUT_SECONDS
        assert _EPOCH_TIMEOUT_SECONDS == 7200, (
            f"Expected 7200s timeout, got {_EPOCH_TIMEOUT_SECONDS}"
        )

    def test_timeout_applied(self, tmp_path: Path):
        """Patch asyncio.wait_for to verify it is called with the correct timeout."""
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")

        with patch("core.epoch.epoch_runner.asyncio.wait_for", wraps=asyncio.wait_for) as mock_wait_for:
            _run(runner.run_epoch_safe())

        mock_wait_for.assert_called_once()
        call_args = mock_wait_for.call_args
        positional = call_args.args
        keyword = call_args.kwargs

        # timeout can be passed positionally (2nd arg) or as keyword
        if "timeout" in keyword:
            timeout_val = keyword["timeout"]
        elif len(positional) >= 2:
            timeout_val = positional[1]
        else:
            timeout_val = None

        assert timeout_val == 7200, (
            f"asyncio.wait_for should be called with timeout=7200, got {timeout_val!r}. "
            f"call_args={call_args!r}"
        )

    def test_run_epoch_safe_returns_none_on_timeout(self, tmp_path: Path):
        """Simulate asyncio.TimeoutError: run_epoch_safe returns None."""
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")

        with patch("core.epoch.epoch_runner.asyncio.wait_for", side_effect=asyncio.TimeoutError()):
            result = _run(runner.run_epoch_safe())

        assert result is None, f"Expected None on timeout, got {result!r}"

    def test_lock_released_on_timeout(self, tmp_path: Path):
        """Lock is released even when asyncio.TimeoutError fires."""
        runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")

        with patch("core.epoch.epoch_runner.asyncio.wait_for", side_effect=asyncio.TimeoutError()):
            _run(runner.run_epoch_safe())

        mocks["lock"].release.assert_called_once()


class TestWB4_PhaseFailurePartialResult:
    """WB4: Phase failure mid-run → success=False, partial phases_completed."""

    def test_conversation_aggregate_failure_does_not_block_rest(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        mocks["aggregator"].aggregate.side_effect = RuntimeError("DB down")

        result = _run(runner.run_epoch("epoch_2026_02_25"))

        # Phase 1 failed, so conversation_aggregate should NOT be in phases
        assert "conversation_aggregate" not in result.phases_completed

        # But subsequent phases may still run (distiller will receive None for conversations)
        # The runner continues — it does not abort on phase failure
        assert isinstance(result, EpochResult)

    def test_distiller_failure_leaves_empty_axioms(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
        mocks["distiller"].distill.side_effect = RuntimeError("LLM unavailable")

        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "axiom_distill" not in result.phases_completed
        assert result.axioms == [], f"Expected empty axioms on distiller failure, got {result.axioms}"

    def test_meta_architect_failure_skips_ontological_phases(self, tmp_path: Path):
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=True)
        mocks["meta_architect"].analyze.side_effect = RuntimeError("Analysis failed")

        result = _run(runner.run_epoch("epoch_2026_02_25"))

        # meta_architect failed → no code_propose or shadow_arena either
        assert "meta_architect" not in result.phases_completed
        assert "code_propose" not in result.phases_completed
        assert "pr_create" not in result.phases_completed

    def test_arena_fail_prevents_pr_but_allows_tier1(self, tmp_path: Path):
        """Arena not passing → no PR created, but tier1_update still runs."""
        runner, mocks = _build_runner(tmp_path=tmp_path, scope="ontological", arena_passed=False)

        result = _run(runner.run_epoch("epoch_2026_02_25"))

        assert "pr_create" not in result.phases_completed, (
            "No PR should be created when arena fails"
        )
        assert "tier1_update" in result.phases_completed, (
            f"tier1_update should still run when arena fails: {result.phases_completed}"
        )
        assert result.pr_url is None


# ===========================================================================
# Additional edge-case tests
# ===========================================================================


def test_epoch_result_epoch_id_preserved(tmp_path: Path):
    """EpochResult.epoch_id matches the epoch_id passed to run_epoch."""
    runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
    result = _run(runner.run_epoch("epoch_2026_03_01"))

    assert result.epoch_id == "epoch_2026_03_01", (
        f"epoch_id mismatch: {result.epoch_id!r}"
    )


def test_epoch_log_path_constant():
    """EPOCH_LOG_PATH constant is on E: drive."""
    assert EPOCH_LOG_PATH.startswith("/mnt/e/"), (
        f"EPOCH_LOG_PATH must be on E: drive, got: {EPOCH_LOG_PATH!r}"
    )
    assert EPOCH_LOG_PATH.endswith("epoch_log.jsonl"), (
        f"EPOCH_LOG_PATH should end with epoch_log.jsonl, got: {EPOCH_LOG_PATH!r}"
    )


def test_package_init_exports():
    """core.epoch __init__ exports EpochRunner and EPOCH_LOG_PATH."""
    from core.epoch import EpochRunner as ER, EPOCH_LOG_PATH as ELP

    assert ER is EpochRunner, "EpochRunner not re-exported from core.epoch"
    assert isinstance(ELP, str), "EPOCH_LOG_PATH should be a string"
    assert ELP.endswith("epoch_log.jsonl"), (
        f"EPOCH_LOG_PATH should end with epoch_log.jsonl, got: {ELP!r}"
    )


def test_week_summary_propagated_to_result(tmp_path: Path):
    """EpochResult.week_summary comes from the distillation result."""
    runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
    mocks["distiller"].distill.return_value = DistillationResult(
        axioms=_make_axioms(2),
        week_summary="Special week: 42 sessions, 0 failures.",
    )

    result = _run(runner.run_epoch("epoch_2026_02_25"))

    assert result.week_summary == "Special week: 42 sessions, 0 failures.", (
        f"week_summary not propagated: {result.week_summary!r}"
    )


def test_tier1_update_count_in_result(tmp_path: Path):
    """tier1_updates in EpochResult reflects counts from Tier1EpochResult."""
    runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
    mocks["tier1_trigger"].apply.return_value = Tier1EpochResult(
        kg_axioms_written=5,
        qdrant_scars_updated=3,
        prompt_templates_updated=2,
        rules_appended=1,
    )

    result = _run(runner.run_epoch("epoch_2026_02_25"))

    # tier1_updates is the sum of kg_axioms_written + prompt_templates_updated + rules_appended
    expected = 5 + 2 + 1
    assert result.tier1_updates == expected, (
        f"Expected tier1_updates={expected}, got {result.tier1_updates}"
    )


def test_report_generator_called(tmp_path: Path):
    """report_generator.generate() is called exactly once per epoch."""
    runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")
    _run(runner.run_epoch("epoch_2026_02_25"))

    mocks["report_generator"].generate.assert_called_once()


def test_multiple_log_entries_appended(tmp_path: Path):
    """Multiple run_epoch calls each append a separate log entry."""
    runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")

    _run(runner.run_epoch("epoch_2026_02_25"))
    _run(runner.run_epoch("epoch_2026_02_26"))

    log_file = Path(runner.epoch_log_path)
    lines = [ln.strip() for ln in log_file.read_text().splitlines() if ln.strip()]
    assert len(lines) == 2, f"Expected 2 log entries, got {len(lines)}"

    ids = [json.loads(ln)["epoch_id"] for ln in lines]
    assert ids == ["epoch_2026_02_25", "epoch_2026_02_26"]


def test_run_epoch_safe_generates_epoch_id_from_date(tmp_path: Path):
    """run_epoch_safe() generates epoch_id as epoch_YYYY_MM_DD."""
    runner, mocks = _build_runner(tmp_path=tmp_path, lock_acquired=True, scope="epistemic")
    result = _run(runner.run_epoch_safe())

    today = datetime.now(timezone.utc).strftime("%Y_%m_%d")
    expected_epoch_id = f"epoch_{today}"

    assert result is not None
    assert result.epoch_id == expected_epoch_id, (
        f"Expected epoch_id={expected_epoch_id!r}, got {result.epoch_id!r}"
    )


def test_oserror_on_log_write_does_not_propagate(tmp_path: Path):
    """OSError during epoch log write must not propagate to caller."""
    runner, mocks = _build_runner(tmp_path=tmp_path, scope="epistemic")

    # Set an unwritable path
    runner.epoch_log_path = "/mnt/e/this/does/not/exist/readonly/epoch_log.jsonl"

    with patch("os.makedirs", side_effect=OSError("Permission denied")):
        # Must not raise
        result = _run(runner.run_epoch("epoch_2026_02_25"))

    assert isinstance(result, EpochResult)


# ===========================================================================
# Standalone runner
# ===========================================================================

if __name__ == "__main__":
    import traceback
    import tempfile

    def _tmp() -> Path:
        return Path(tempfile.mkdtemp())

    test_fns = [
        # BB1
        ("BB1: pr_url set for ontological+arena_pass", lambda: TestBB1_OntologicalEpochWithPR().test_pr_url_is_set(_tmp())),
        ("BB1: pr_create in phases", lambda: TestBB1_OntologicalEpochWithPR().test_pr_create_in_phases_completed(_tmp())),
        ("BB1: code_propose in phases", lambda: TestBB1_OntologicalEpochWithPR().test_code_propose_in_phases_completed(_tmp())),
        ("BB1: shadow_arena in phases", lambda: TestBB1_OntologicalEpochWithPR().test_shadow_arena_in_phases_completed(_tmp())),
        ("BB1: core phases present", lambda: TestBB1_OntologicalEpochWithPR().test_core_phases_all_present(_tmp())),
        ("BB1: shadow_pass_rate set", lambda: TestBB1_OntologicalEpochWithPR().test_shadow_pass_rate_set(_tmp())),
        # BB2
        ("BB2: pr_url is None for epistemic", lambda: TestBB2_EpistemicEpochNoCodeProposal().test_pr_url_is_none(_tmp())),
        ("BB2: no code_propose for epistemic", lambda: TestBB2_EpistemicEpochNoCodeProposal().test_no_code_propose_in_phases(_tmp())),
        ("BB2: no shadow_arena for epistemic", lambda: TestBB2_EpistemicEpochNoCodeProposal().test_no_shadow_arena_in_phases(_tmp())),
        ("BB2: no pr_create for epistemic", lambda: TestBB2_EpistemicEpochNoCodeProposal().test_no_pr_create_in_phases(_tmp())),
        ("BB2: tier1_update in phases", lambda: TestBB2_EpistemicEpochNoCodeProposal().test_tier1_update_in_phases(_tmp())),
        # BB3
        ("BB3: returns None when lock held", lambda: TestBB3_RunEpochSafeLockHeld().test_returns_none_when_lock_held(_tmp())),
        ("BB3: no epoch runs when lock held", lambda: TestBB3_RunEpochSafeLockHeld().test_no_epoch_runs_when_lock_held(_tmp())),
        ("BB3: returns result when lock acquired", lambda: TestBB3_RunEpochSafeLockHeld().test_returns_result_when_lock_acquired(_tmp())),
        ("BB3: lock released after run", lambda: TestBB3_RunEpochSafeLockHeld().test_lock_released_after_successful_run(_tmp())),
        ("BB3: lock released on error", lambda: TestBB3_RunEpochSafeLockHeld().test_lock_released_even_on_error(_tmp())),
        # BB4
        ("BB4: log file created", lambda: TestBB4_EpochLogWritten().test_log_file_created(_tmp())),
        ("BB4: log epoch_id correct", lambda: TestBB4_EpochLogWritten().test_log_entry_has_correct_epoch_id(_tmp())),
        ("BB4: log has phases_completed", lambda: TestBB4_EpochLogWritten().test_log_entry_has_phases_completed(_tmp())),
        ("BB4: log has duration", lambda: TestBB4_EpochLogWritten().test_log_entry_has_duration(_tmp())),
        ("BB4: log has timestamp", lambda: TestBB4_EpochLogWritten().test_log_entry_has_timestamp(_tmp())),
        ("BB4: log axioms_count=3", lambda: TestBB4_EpochLogWritten().test_log_entry_axioms_count(_tmp())),
        # WB1
        ("WB1: aggregator before distiller", lambda: TestWB1_SequentialExecution().test_aggregator_called_before_distiller(_tmp())),
        ("WB1: distiller before knowledge_writer", lambda: TestWB1_SequentialExecution().test_distiller_called_before_knowledge_writer(_tmp())),
        ("WB1: knowledge_writer before meta_architect", lambda: TestWB1_SequentialExecution().test_meta_architect_called_after_knowledge_writer(_tmp())),
        ("WB1: meta_architect before tier1", lambda: TestWB1_SequentialExecution().test_tier1_trigger_called_after_meta_architect(_tmp())),
        # WB2
        ("WB2: lock acquired before aggregator", lambda: TestWB2_LockAcquireAndRelease().test_lock_acquired_before_aggregator(_tmp())),
        ("WB2: lock released after epoch", lambda: TestWB2_LockAcquireAndRelease().test_lock_released_after_epoch(_tmp())),
        ("WB2: lock released on exception", lambda: TestWB2_LockAcquireAndRelease().test_lock_released_on_phase_exception(_tmp())),
        # WB3
        ("WB3: timeout constant is 7200", TestWB3_AsyncTimeoutUsed().test_timeout_constant_is_7200),
        ("WB3: timeout applied", lambda: TestWB3_AsyncTimeoutUsed().test_timeout_applied(_tmp())),
        ("WB3: None on timeout", lambda: TestWB3_AsyncTimeoutUsed().test_run_epoch_safe_returns_none_on_timeout(_tmp())),
        ("WB3: lock released on timeout", lambda: TestWB3_AsyncTimeoutUsed().test_lock_released_on_timeout(_tmp())),
        # WB4
        ("WB4: phase failure does not block rest", lambda: TestWB4_PhaseFailurePartialResult().test_conversation_aggregate_failure_does_not_block_rest(_tmp())),
        ("WB4: distiller failure empty axioms", lambda: TestWB4_PhaseFailurePartialResult().test_distiller_failure_leaves_empty_axioms(_tmp())),
        ("WB4: meta_architect failure skips ontological", lambda: TestWB4_PhaseFailurePartialResult().test_meta_architect_failure_skips_ontological_phases(_tmp())),
        ("WB4: arena fail → no PR but tier1 runs", lambda: TestWB4_PhaseFailurePartialResult().test_arena_fail_prevents_pr_but_allows_tier1(_tmp())),
        # Additional
        ("EDGE: epoch_id preserved", lambda: test_epoch_result_epoch_id_preserved(_tmp())),
        ("EDGE: EPOCH_LOG_PATH on E: drive", test_epoch_log_path_constant),
        ("EDGE: package exports", test_package_init_exports),
        ("EDGE: week_summary propagated", lambda: test_week_summary_propagated_to_result(_tmp())),
        ("EDGE: tier1_updates sum correct", lambda: test_tier1_update_count_in_result(_tmp())),
        ("EDGE: report_generator called once", lambda: test_report_generator_called(_tmp())),
        ("EDGE: multiple log entries", lambda: test_multiple_log_entries_appended(_tmp())),
        ("EDGE: run_epoch_safe epoch_id from date", lambda: test_run_epoch_safe_generates_epoch_id_from_date(_tmp())),
        ("EDGE: OSError on log write does not propagate", lambda: test_oserror_on_log_write_does_not_propagate(_tmp())),
    ]

    passed = 0
    total = len(test_fns)
    for name, fn in test_fns:
        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.02 (Track B)")
    else:
        sys.exit(1)
