"""
Story 9.04 — Test Suite
========================
NightlyEpochRunner: Axiom Write to KG

File under test: core/epoch/nightly_epoch_runner.py

BB Tests (5):
  BB1: 5 axioms → 5 entries in Qdrant + 5 lines in jsonl
  BB2: week_summary → line written to weekly_summaries.jsonl
  BB3: Return count matches axioms written
  BB4: Empty axioms list → returns 0, axiom file NOT modified
  BB5: week_summary still written even when axioms list is empty

WB Tests (6):
  WB1: Qdrant upsert (not insert — idempotent on re-run)
  WB2: jsonl append (not overwrite — file grows, never truncated)
  WB3: Qdrant failure for one axiom → others still written (partial success)
  WB4: None qdrant_client → file writes still happen
  WB5: None gemini_client → file writes still happen (skip embed+upsert)
  WB6: os.makedirs called for parent dirs (tmp_path isolation)

All tests use MagicMock / AsyncMock — zero live API calls.
All file I/O uses tmp_path pytest fixture to redirect KG_AXIOM_PATH and
WEEKLY_SUMMARY_PATH so the real KG files are never touched.
"""
from __future__ import annotations

import asyncio
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, call, patch

import pytest

import sys
sys.path.insert(0, "/mnt/e/genesis-system")

from core.epoch.nightly_epoch_runner import (
    KG_AXIOM_PATH,
    QDRANT_COLLECTION,
    WEEKLY_SUMMARY_PATH,
    NightlyEpochRunner,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _arun(coro):
    """Run a coroutine synchronously."""
    return asyncio.run(coro)


def _make_axioms(n: int) -> list[dict]:
    """Build *n* valid axiom dicts."""
    categories = ["preference", "fact", "strategy", "directive"]
    return [
        {
            "id": f"epoch_2026_02_25_{i:03d}",
            "content": f"Axiom content number {i}",
            "category": categories[i % len(categories)],
            "confidence": round(0.7 + (i % 3) * 0.1, 1),
        }
        for i in range(n)
    ]


def _make_vector(dims: int = 768) -> list[float]:
    """Return a deterministic fake embedding vector."""
    return [round(0.1 + (i % 10) * 0.01, 3) for i in range(dims)]


def _make_gemini_client(vector: list[float] | None = None) -> MagicMock:
    """
    Return a MagicMock Gemini client whose embed() AsyncMock returns *vector*.
    Defaults to a 768-dim fake vector.
    """
    v = vector if vector is not None else _make_vector()
    client = MagicMock()
    client.embed = AsyncMock(return_value=v)
    client.generate = AsyncMock(return_value="{}")
    return client


def _make_qdrant_client() -> MagicMock:
    """Return a MagicMock Qdrant client."""
    client = MagicMock()
    client.upsert = MagicMock(return_value=None)
    return client


def _patch_paths(tmp_path: Path):
    """
    Return a context manager that redirects KG_AXIOM_PATH and
    WEEKLY_SUMMARY_PATH to files inside *tmp_path*.
    """
    axiom_file = str(tmp_path / "axioms.jsonl")
    summary_file = str(tmp_path / "summaries.jsonl")
    return (
        axiom_file,
        summary_file,
        patch(
            "core.epoch.nightly_epoch_runner.KG_AXIOM_PATH",
            axiom_file,
        ),
        patch(
            "core.epoch.nightly_epoch_runner.WEEKLY_SUMMARY_PATH",
            summary_file,
        ),
    )


# ---------------------------------------------------------------------------
# BB1 — 5 axioms → 5 entries in Qdrant + 5 lines in jsonl
# ---------------------------------------------------------------------------


class TestBB1_FiveAxiomsWritten:
    """BB1: 5 axioms → 5 Qdrant upserts + 5 lines in genesis_evolution_learnings.jsonl."""

    def test_five_qdrant_upserts_called(self, tmp_path):
        """BB1: qdrant.upsert() must be called once per axiom (5 calls total)."""
        axioms = _make_axioms(5)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Good week."))

        assert qdrant.upsert.call_count == 5, (
            f"Expected 5 Qdrant upserts, got {qdrant.upsert.call_count}"
        )

    def test_five_lines_written_to_jsonl(self, tmp_path):
        """BB1: 5 axioms → 5 JSON lines in genesis_evolution_learnings.jsonl."""
        axioms = _make_axioms(5)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Good week."))

        lines = Path(axiom_file).read_text().strip().splitlines()
        assert len(lines) == 5, (
            f"Expected 5 lines in axioms jsonl, got {len(lines)}"
        )

    def test_each_jsonl_line_is_valid_json(self, tmp_path):
        """BB1: Every line in the jsonl file must be valid JSON."""
        axioms = _make_axioms(5)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Good week."))

        for i, line in enumerate(Path(axiom_file).read_text().strip().splitlines()):
            parsed = json.loads(line)
            assert isinstance(parsed, dict), (
                f"Line {i} is not a JSON object: {line!r}"
            )

    def test_qdrant_upsert_called_with_correct_collection(self, tmp_path):
        """BB1: Each upsert must target the 'genesis_axioms' collection."""
        axioms = _make_axioms(3)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Test."))

        for c in qdrant.upsert.call_args_list:
            kwargs = c.kwargs if c.kwargs else {}
            args = c.args if c.args else ()
            # collection_name may be positional or keyword
            collection = kwargs.get("collection_name") or (args[0] if args else None)
            assert collection == QDRANT_COLLECTION, (
                f"Expected collection_name='{QDRANT_COLLECTION}', got {collection!r}"
            )

    def test_jsonl_axiom_ids_match_input(self, tmp_path):
        """BB1: Axiom IDs written to jsonl must match the input axiom IDs."""
        axioms = _make_axioms(5)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Good week."))

        written_ids = {
            json.loads(line)["id"]
            for line in Path(axiom_file).read_text().strip().splitlines()
        }
        expected_ids = {ax["id"] for ax in axioms}
        assert written_ids == expected_ids, (
            f"Axiom IDs mismatch: written={written_ids}, expected={expected_ids}"
        )


# ---------------------------------------------------------------------------
# BB2 — week_summary written to weekly_summaries.jsonl
# ---------------------------------------------------------------------------


class TestBB2_WeeklySummaryWritten:
    """BB2: week_summary must be persisted to weekly_summaries.jsonl."""

    def test_summary_file_created_with_one_line(self, tmp_path):
        """BB2: A single JSON line is written to weekly_summaries.jsonl."""
        axioms = _make_axioms(3)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Big week for Genesis."))

        lines = Path(summary_file).read_text().strip().splitlines()
        assert len(lines) == 1, (
            f"Expected exactly 1 summary line, got {len(lines)}: {lines}"
        )

    def test_summary_line_contains_summary_text(self, tmp_path):
        """BB2: The JSON line must include the exact week_summary text."""
        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Deployed voice widget."))

        entry = json.loads(Path(summary_file).read_text().strip())
        assert entry["summary"] == "Deployed voice widget.", (
            f"Expected summary='Deployed voice widget.', got {entry['summary']!r}"
        )

    def test_summary_line_contains_date_key(self, tmp_path):
        """BB2: The weekly summary entry must include a 'date' key."""
        axioms = _make_axioms(1)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Short week."))

        entry = json.loads(Path(summary_file).read_text().strip())
        assert "date" in entry, (
            f"Weekly summary entry missing 'date' key: {entry!r}"
        )
        # Date must look like YYYY-MM-DD
        assert len(entry["date"]) == 10 and entry["date"][4] == "-", (
            f"Date format must be YYYY-MM-DD, got {entry['date']!r}"
        )


# ---------------------------------------------------------------------------
# BB3 — Return count matches axioms written
# ---------------------------------------------------------------------------


class TestBB3_ReturnCountMatchesWritten:
    """BB3: write_axioms() must return the count of axioms successfully written."""

    def test_return_count_equals_input_count_on_success(self, tmp_path):
        """BB3: All 5 axioms succeed → returns 5."""
        axioms = _make_axioms(5)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "Good week."))

        assert result == 5, (
            f"Expected return count=5, got {result}"
        )

    def test_return_count_is_int(self, tmp_path):
        """BB3: Return value must be an integer."""
        axioms = _make_axioms(3)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "Test."))

        assert isinstance(result, int), (
            f"Expected int return value, got {type(result)}: {result!r}"
        )

    def test_return_count_one_axiom(self, tmp_path):
        """BB3: Single axiom → returns 1."""
        axioms = _make_axioms(1)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "One axiom week."))

        assert result == 1, f"Expected 1 for single axiom, got {result}"


# ---------------------------------------------------------------------------
# BB4 — Empty axioms list → returns 0, axiom file NOT modified
# ---------------------------------------------------------------------------


class TestBB4_EmptyAxiomsReturnsZero:
    """BB4: Empty axioms list must return 0 without writing to the axiom file."""

    def test_empty_list_returns_zero(self, tmp_path):
        """BB4: write_axioms([]) → returns 0."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms([], "Empty week."))

        assert result == 0, f"Expected 0 for empty axioms, got {result}"

    def test_empty_list_does_not_create_axiom_file(self, tmp_path):
        """BB4: Axiom jsonl file must NOT be created when axioms is empty."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms([], "Empty week."))

        assert not Path(axiom_file).exists(), (
            "Axiom jsonl file must NOT be created when axioms list is empty"
        )

    def test_empty_list_does_not_call_qdrant(self, tmp_path):
        """BB4: Qdrant upsert must NOT be called when axioms list is empty."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms([], "Empty week."))

        qdrant.upsert.assert_not_called()

    def test_empty_list_does_not_call_embed(self, tmp_path):
        """BB4: embed() must NOT be called when axioms list is empty."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms([], "Empty week."))

        gemini.embed.assert_not_called()


# ---------------------------------------------------------------------------
# BB5 — week_summary written even when axioms list is empty
# ---------------------------------------------------------------------------


class TestBB5_WeeklySummaryWrittenEvenOnEmptyAxioms:
    """BB5: weekly_summaries.jsonl must be written even when axioms is []."""

    def test_summary_written_despite_empty_axioms(self, tmp_path):
        """BB5: Summary file created even with empty axioms list."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms([], "Quiet week."))

        assert Path(summary_file).exists(), (
            "weekly_summaries.jsonl must be created even when axioms is empty"
        )

    def test_summary_text_correct_with_empty_axioms(self, tmp_path):
        """BB5: The summary text in the file matches the input week_summary."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms([], "Nothing happened."))

        entry = json.loads(Path(summary_file).read_text().strip())
        assert entry["summary"] == "Nothing happened.", (
            f"Expected 'Nothing happened.', got {entry['summary']!r}"
        )


# ---------------------------------------------------------------------------
# WB1 — Qdrant upsert (not insert — idempotent on re-run)
# ---------------------------------------------------------------------------


class TestWB1_QdrantUpsertNotInsert:
    """WB1: write_axioms() must call upsert() — not insert() — for idempotency."""

    def test_upsert_method_called_not_insert(self, tmp_path):
        """WB1: qdrant.upsert() is called, not qdrant.insert()."""
        axioms = _make_axioms(3)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Test."))

        # upsert must be called, insert must NOT
        assert qdrant.upsert.call_count == 3, (
            f"Expected 3 upsert calls, got {qdrant.upsert.call_count}"
        )
        qdrant.insert.assert_not_called() if hasattr(qdrant, "insert") else None

    def test_second_run_same_axioms_still_upserts(self, tmp_path):
        """WB1: Re-running with same axioms calls upsert again (idempotent)."""
        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "First run."))
            _arun(runner.write_axioms(axioms, "Second run."))

        # 2 axioms × 2 runs = 4 upsert calls total
        assert qdrant.upsert.call_count == 4, (
            f"Expected 4 total upsert calls after 2 runs, got {qdrant.upsert.call_count}"
        )

    def test_upsert_receives_point_struct_with_correct_id(self, tmp_path):
        """WB1: Each PointStruct passed to upsert must have the axiom's id."""
        from qdrant_client.models import PointStruct

        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Test."))

        for i, c in enumerate(qdrant.upsert.call_args_list):
            kwargs = c.kwargs if c.kwargs else {}
            points = kwargs.get("points") or (c.args[1] if len(c.args) > 1 else None)
            assert points is not None and len(points) == 1, (
                f"Call {i}: expected 1 point, got {points!r}"
            )
            point = points[0]
            assert point.id == axioms[i]["id"], (
                f"PointStruct id mismatch at call {i}: "
                f"expected {axioms[i]['id']!r}, got {point.id!r}"
            )


# ---------------------------------------------------------------------------
# WB2 — jsonl append (not overwrite — file grows on re-run)
# ---------------------------------------------------------------------------


class TestWB2_JsonlAppendNotOverwrite:
    """WB2: write_axioms() must append to the jsonl file, never overwrite it."""

    def test_second_run_appends_to_existing_file(self, tmp_path):
        """WB2: Running twice → file has 2× axiom lines (not reset to first run)."""
        axioms = _make_axioms(3)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Week 1."))
            _arun(runner.write_axioms(axioms, "Week 2."))

        lines = Path(axiom_file).read_text().strip().splitlines()
        assert len(lines) == 6, (
            f"Expected 6 lines after 2 runs of 3 axioms, got {len(lines)}"
        )

    def test_pre_existing_content_preserved(self, tmp_path):
        """WB2: Pre-existing content in the jsonl file is never erased."""
        axiom_file_path = tmp_path / "axioms.jsonl"
        summary_file_path = tmp_path / "summaries.jsonl"
        pre_existing = json.dumps({"id": "existing_001", "content": "pre-existing"})
        axiom_file_path.write_text(pre_existing + "\n")

        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()

        with (
            patch("core.epoch.nightly_epoch_runner.KG_AXIOM_PATH", str(axiom_file_path)),
            patch("core.epoch.nightly_epoch_runner.WEEKLY_SUMMARY_PATH", str(summary_file_path)),
        ):
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Week."))

        lines = axiom_file_path.read_text().strip().splitlines()
        # Pre-existing line must still be there
        assert any("existing_001" in line for line in lines), (
            "Pre-existing content was erased — file was overwritten instead of appended"
        )
        # Plus 2 new axiom lines
        assert len(lines) == 3, (
            f"Expected 3 lines (1 pre-existing + 2 new), got {len(lines)}: {lines}"
        )

    def test_summary_file_appends_on_second_run(self, tmp_path):
        """WB2: weekly_summaries.jsonl also appends across runs."""
        axioms = _make_axioms(1)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Week 1."))
            _arun(runner.write_axioms(axioms, "Week 2."))

        lines = Path(summary_file).read_text().strip().splitlines()
        assert len(lines) == 2, (
            f"Expected 2 summary lines after 2 runs, got {len(lines)}"
        )


# ---------------------------------------------------------------------------
# WB3 — Qdrant failure for one axiom → others still written (partial success)
# ---------------------------------------------------------------------------


class TestWB3_QdrantFailurePartialSuccess:
    """WB3: A Qdrant error for one axiom must not block the others."""

    def test_qdrant_failure_on_second_axiom_others_still_written(self, tmp_path):
        """WB3: If axiom[1] Qdrant upsert fails, axioms[0] and [2] still reach jsonl."""
        axioms = _make_axioms(3)
        gemini = _make_gemini_client()

        # Make qdrant.upsert raise on the second call only
        call_count = [0]

        def _failing_upsert(**kwargs):
            call_count[0] += 1
            if call_count[0] == 2:
                raise RuntimeError("Qdrant connection refused")
            return None

        qdrant = _make_qdrant_client()
        qdrant.upsert.side_effect = _failing_upsert

        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "Partial week."))

        # All 3 axioms must be written to the jsonl (Qdrant failure != file failure)
        lines = Path(axiom_file).read_text().strip().splitlines()
        assert len(lines) == 3, (
            f"Expected 3 jsonl lines despite Qdrant failure, got {len(lines)}"
        )
        assert result == 3, (
            f"Expected return count=3, got {result}"
        )

    def test_qdrant_failure_does_not_raise(self, tmp_path):
        """WB3: Qdrant upsert failure must never propagate out of write_axioms()."""
        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        qdrant.upsert.side_effect = RuntimeError("Qdrant down")

        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            try:
                result = _arun(runner.write_axioms(axioms, "Failing week."))
            except Exception as exc:
                pytest.fail(
                    f"write_axioms() must not propagate Qdrant exceptions, "
                    f"but raised: {exc}"
                )

        # File writes still happened
        assert Path(axiom_file).exists(), (
            "Axiom file must still be written despite Qdrant failure"
        )

    def test_return_count_unaffected_by_qdrant_failure(self, tmp_path):
        """WB3: Return count reflects jsonl writes, not Qdrant success."""
        axioms = _make_axioms(4)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        qdrant.upsert.side_effect = RuntimeError("Qdrant down")

        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "Test."))

        # All 4 axioms must be counted as written (to the jsonl)
        assert result == 4, (
            f"Expected return count=4 (jsonl success, Qdrant failed), got {result}"
        )


# ---------------------------------------------------------------------------
# WB4 — None qdrant_client → file writes still happen
# ---------------------------------------------------------------------------


class TestWB4_NoneQdrantClientFileWritesStillHappen:
    """WB4: qdrant_client=None must not prevent jsonl writes."""

    def test_none_qdrant_axioms_written_to_file(self, tmp_path):
        """WB4: write_axioms() with qdrant_client=None → axioms jsonl written."""
        axioms = _make_axioms(4)
        gemini = _make_gemini_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=None
            )
            result = _arun(runner.write_axioms(axioms, "No Qdrant week."))

        lines = Path(axiom_file).read_text().strip().splitlines()
        assert len(lines) == 4, (
            f"Expected 4 axiom lines with qdrant=None, got {len(lines)}"
        )
        assert result == 4, (
            f"Expected return count=4, got {result}"
        )

    def test_none_qdrant_summary_written(self, tmp_path):
        """WB4: weekly_summaries.jsonl still written when qdrant_client=None."""
        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=None
            )
            _arun(runner.write_axioms(axioms, "Summary without Qdrant."))

        entry = json.loads(Path(summary_file).read_text().strip())
        assert entry["summary"] == "Summary without Qdrant.", (
            f"Unexpected summary: {entry['summary']!r}"
        )

    def test_none_qdrant_stored_as_attribute(self):
        """WB4: runner.qdrant attribute must be None when qdrant_client=None."""
        runner = NightlyEpochRunner(qdrant_client=None)
        assert runner.qdrant is None, (
            f"Expected runner.qdrant to be None, got {runner.qdrant!r}"
        )

    def test_qdrant_client_stored_as_attribute(self):
        """WB4: runner.qdrant must store the provided qdrant_client object."""
        qdrant = _make_qdrant_client()
        runner = NightlyEpochRunner(qdrant_client=qdrant)
        assert runner.qdrant is qdrant, (
            "qdrant_client must be stored as runner.qdrant"
        )


# ---------------------------------------------------------------------------
# WB5 — None gemini_client → file writes still happen (skip embed+upsert)
# ---------------------------------------------------------------------------


class TestWB5_NoneGeminiClientFileWritesStillHappen:
    """WB5: gemini_client=None must not prevent jsonl writes."""

    def test_none_gemini_axioms_written_to_file(self, tmp_path):
        """WB5: write_axioms() with gemini_client=None → axioms jsonl written."""
        axioms = _make_axioms(3)
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=None, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "No Gemini week."))

        lines = Path(axiom_file).read_text().strip().splitlines()
        assert len(lines) == 3, (
            f"Expected 3 axiom lines with gemini=None, got {len(lines)}"
        )
        assert result == 3, (
            f"Expected return count=3, got {result}"
        )

    def test_none_gemini_skips_qdrant_upsert(self, tmp_path):
        """WB5: When gemini=None, no vector available → Qdrant upsert must NOT run."""
        axioms = _make_axioms(3)
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=None, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "No Gemini."))

        qdrant.upsert.assert_not_called()

    def test_none_gemini_summary_written(self, tmp_path):
        """WB5: weekly_summaries.jsonl still written when gemini_client=None."""
        axioms = _make_axioms(1)
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=None, qdrant_client=qdrant
            )
            _arun(runner.write_axioms(axioms, "Summary without Gemini."))

        entry = json.loads(Path(summary_file).read_text().strip())
        assert entry["summary"] == "Summary without Gemini.", (
            f"Unexpected summary: {entry['summary']!r}"
        )

    def test_none_gemini_return_count_correct(self, tmp_path):
        """WB5: Return count reflects jsonl writes even without Gemini embedding."""
        axioms = _make_axioms(5)
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)
        with p1, p2:
            runner = NightlyEpochRunner(
                gemini_client=None, qdrant_client=qdrant
            )
            result = _arun(runner.write_axioms(axioms, "Test."))

        assert result == 5, (
            f"Expected return count=5 with gemini=None, got {result}"
        )


# ---------------------------------------------------------------------------
# WB6 — os.makedirs called for parent dirs (tmp_path isolation confirms this)
# ---------------------------------------------------------------------------


class TestWB6_OsMakedirsCalled:
    """WB6: write_axioms() must create parent directories if they don't exist."""

    def test_nested_dirs_created_automatically(self, tmp_path):
        """WB6: Deep nested path created without manual mkdir."""
        deep_axiom = str(tmp_path / "a" / "b" / "c" / "axioms.jsonl")
        deep_summary = str(tmp_path / "x" / "y" / "z" / "summaries.jsonl")

        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()

        with (
            patch("core.epoch.nightly_epoch_runner.KG_AXIOM_PATH", deep_axiom),
            patch("core.epoch.nightly_epoch_runner.WEEKLY_SUMMARY_PATH", deep_summary),
        ):
            runner = NightlyEpochRunner(
                gemini_client=gemini, qdrant_client=qdrant
            )
            # Must NOT raise FileNotFoundError
            try:
                result = _arun(runner.write_axioms(axioms, "Deep dirs."))
            except FileNotFoundError as exc:
                pytest.fail(
                    f"write_axioms() must create parent dirs automatically, "
                    f"but raised FileNotFoundError: {exc}"
                )

        assert Path(deep_axiom).exists(), (
            "Axiom file must exist at deeply nested path after makedirs"
        )
        assert Path(deep_summary).exists(), (
            "Summary file must exist at deeply nested path after makedirs"
        )

    def test_makedirs_called_with_exist_ok(self, tmp_path):
        """WB6: os.makedirs must be called with exist_ok=True (no error if dir exists)."""
        axioms = _make_axioms(2)
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        axiom_file, summary_file, p1, p2 = _patch_paths(tmp_path)

        with p1, p2:
            with patch("core.epoch.nightly_epoch_runner.os.makedirs") as mock_makedirs:
                runner = NightlyEpochRunner(
                    gemini_client=gemini, qdrant_client=qdrant
                )
                _arun(runner.write_axioms(axioms, "Test."))

        # os.makedirs must have been called at least twice (axiom dir + summary dir)
        assert mock_makedirs.call_count >= 2, (
            f"Expected os.makedirs called >=2 times, got {mock_makedirs.call_count}"
        )
        # All calls must use exist_ok=True
        for c in mock_makedirs.call_args_list:
            kwargs = c.kwargs if c.kwargs else {}
            assert kwargs.get("exist_ok") is True, (
                f"os.makedirs must be called with exist_ok=True, "
                f"got call: {c}"
            )


# ---------------------------------------------------------------------------
# Regression — existing stories 9.02 and 9.03 still work
# ---------------------------------------------------------------------------


class TestRegression_PriorStoriesUnchanged:
    """Regression: Stories 9.02 and 9.03 must pass after 9.04 changes."""

    def test_aggregate_week_no_pg_returns_empty_list(self):
        """Regression 9.02: aggregate_week() with pg=None returns []."""
        runner = NightlyEpochRunner()
        result = _arun(runner.aggregate_week())
        assert result == []

    def test_distill_empty_conversations(self):
        """Regression 9.03: distill([]) returns safe empty result."""
        runner = NightlyEpochRunner()
        result = _arun(runner.distill([]))
        assert result == {
            "axioms": [],
            "week_summary": "No conversations this week",
        }

    def test_init_accepts_three_optional_params(self):
        """Regression: __init__ signature still accepts pg_conn, gemini_client, qdrant_client."""
        gemini = _make_gemini_client()
        qdrant = _make_qdrant_client()
        pg = MagicMock()
        runner = NightlyEpochRunner(
            pg_conn=pg, gemini_client=gemini, qdrant_client=qdrant
        )
        assert runner.pg is pg
        assert runner.gemini is gemini
        assert runner.qdrant is qdrant

    def test_init_default_params_are_all_none(self):
        """Regression: Default constructor sets pg, gemini, qdrant to None."""
        runner = NightlyEpochRunner()
        assert runner.pg is None
        assert runner.gemini is None
        assert runner.qdrant is None

    def test_module_constants_present(self):
        """Regression: All module constants exist and are strings."""
        from core.epoch.nightly_epoch_runner import (
            KG_AXIOM_PATH,
            QDRANT_COLLECTION,
            WEEKLY_SUMMARY_PATH,
        )
        assert isinstance(KG_AXIOM_PATH, str)
        assert isinstance(WEEKLY_SUMMARY_PATH, str)
        assert isinstance(QDRANT_COLLECTION, str)
        assert QDRANT_COLLECTION == "genesis_axioms"
        assert "genesis_evolution_learnings.jsonl" in KG_AXIOM_PATH
        assert "weekly_summaries.jsonl" in WEEKLY_SUMMARY_PATH


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import sys as _sys
    result = pytest.main([__file__, "-v", "--tb=short"])
    _sys.exit(result)
