"""
tests/test_batch_ingest.py — Unit tests for the batch_ingest pipeline.

Tests cover:
- Text chunking logic
- Time range filtering in SessionLogSource
- FileSource parsing
- TextSource chunking
- Idempotency store behaviour
- BatchIngestor dry-run mode
- BatchSummary accumulation
"""
from __future__ import annotations

import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from cortex.batch_ingest import (
    BatchIngestor,
    BatchSummary,
    Chunk,
    FileSource,
    IdempotencyStore,
    SessionLogSource,
    TextSource,
    _chunk_text,
    run_batch_ingest,
)


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

def _make_session_jsonl(messages: list[dict], base_ts: str = "2026-03-13T10:00:00Z") -> str:
    """Build a minimal OpenClaw JSONL session string."""
    lines = [
        json.dumps({"type": "session", "version": 3, "id": "test-session-id",
                    "timestamp": base_ts, "cwd": "/tmp"}),
    ]
    for i, msg in enumerate(messages):
        ts = msg.get("ts", base_ts)
        lines.append(json.dumps({
            "type": "message",
            "id": f"msg-{i:04d}",
            "parentId": None,
            "timestamp": ts,
            "message": {
                "role": msg["role"],
                "content": msg.get("content", [{"type": "text", "text": msg.get("text", "")}]),
                "timestamp": ts,
            },
        }))
    return "\n".join(lines) + "\n"


# ---------------------------------------------------------------------------
# _chunk_text
# ---------------------------------------------------------------------------

class TestChunkText:
    def test_short_text_returns_single_chunk(self):
        text = "Hello world"
        result = _chunk_text(text, max_chars=2000)
        assert result == ["Hello world"]

    def test_long_text_splits_on_paragraphs(self):
        # Two paragraphs each ~1200 chars
        para = "x" * 1200
        text = para + "\n\n" + para
        result = _chunk_text(text, max_chars=1500)
        assert len(result) == 2
        assert all(len(c) <= 1500 for c in result)

    def test_exact_max_stays_single_chunk(self):
        text = "a" * 2000
        result = _chunk_text(text, max_chars=2000)
        assert len(result) == 1

    def test_empty_text_returns_empty_list(self):
        result = _chunk_text("", max_chars=2000)
        assert result == []

    def test_whitespace_only_filtered(self):
        # Both chunks fit in max_chars=200, so they may be combined into 1 chunk
        # but no empty/whitespace-only chunks should appear
        text = "good chunk\n\n\n\n\n\ngood chunk 2"
        result = _chunk_text(text, max_chars=200)
        assert len(result) >= 1
        assert all(c.strip() for c in result)
        combined = "\n".join(result)
        assert "good chunk" in combined
        assert "good chunk 2" in combined

    def test_multiple_paragraphs_grouped(self):
        # 5 short paragraphs, max_chars=1000 → should fit in 1 chunk
        paras = ["Short para " + str(i) for i in range(5)]
        text = "\n\n".join(paras)
        result = _chunk_text(text, max_chars=1000)
        assert len(result) == 1
        assert all(p in result[0] for p in paras)

    def test_very_long_single_paragraph_splits_on_lines(self):
        # Single paragraph with many lines, each 200 chars
        lines = ["L" * 200 for _ in range(20)]
        text = "\n".join(lines)  # one big paragraph (no \n\n)
        result = _chunk_text(text, max_chars=500)
        assert len(result) >= 2
        assert all(len(c) <= 600 for c in result)  # some slack for newline grouping


# ---------------------------------------------------------------------------
# Chunk
# ---------------------------------------------------------------------------

class TestChunk:
    def test_chunk_hash_is_deterministic(self):
        c = Chunk(content="hello", session_id="s1", chunk_index=0, source_type="text")
        assert c.chunk_hash == c.chunk_hash
        c2 = Chunk(content="hello", session_id="s1", chunk_index=0, source_type="text")
        assert c.chunk_hash == c2.chunk_hash

    def test_chunk_hash_differs_for_different_content(self):
        c1 = Chunk(content="hello", session_id="s1", chunk_index=0, source_type="text")
        c2 = Chunk(content="world", session_id="s1", chunk_index=0, source_type="text")
        assert c1.chunk_hash != c2.chunk_hash

    def test_ingest_session_id_format(self):
        c = Chunk(content="x", session_id="abc-123", chunk_index=2, source_type="sessions")
        assert "batch-sessions-abc-123-2" == c.ingest_session_id

    def test_ingest_session_id_sanitizes_slashes(self):
        c = Chunk(content="x", session_id="path/to/session", chunk_index=0, source_type="sessions")
        assert "/" not in c.ingest_session_id


# ---------------------------------------------------------------------------
# IdempotencyStore
# ---------------------------------------------------------------------------

class TestIdempotencyStore:
    def test_new_hash_not_processed(self):
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
            path = f.name
        try:
            store = IdempotencyStore(state_path=path)
            assert not store.is_processed("abc123")
        finally:
            os.unlink(path)

    def test_mark_then_check(self):
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
            path = f.name
        try:
            store = IdempotencyStore(state_path=path)
            store.mark_processed("abc123")
            assert store.is_processed("abc123")
            assert not store.is_processed("def456")
        finally:
            os.unlink(path)

    def test_persists_across_instances(self):
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
            path = f.name
        try:
            store1 = IdempotencyStore(state_path=path)
            store1.mark_processed("hash1")
            store1.save()

            store2 = IdempotencyStore(state_path=path)
            assert store2.is_processed("hash1")
            assert not store2.is_processed("hash2")
        finally:
            os.unlink(path)

    def test_save_creates_valid_json(self):
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
            path = f.name
        try:
            store = IdempotencyStore(state_path=path)
            store.mark_processed("h1")
            store.mark_processed("h2")
            store.save()
            data = json.loads(Path(path).read_text())
            assert "processed_hashes" in data
            assert "h1" in data["processed_hashes"]
            assert "h2" in data["processed_hashes"]
        finally:
            os.unlink(path)


# ---------------------------------------------------------------------------
# TextSource
# ---------------------------------------------------------------------------

class TestTextSource:
    def test_short_text_yields_single_chunk(self):
        src = TextSource(text="Hello world", label="test")
        chunks = list(src.iter_chunks())
        assert len(chunks) == 1
        assert chunks[0].content == "Hello world"
        assert chunks[0].source_type == "text"
        assert chunks[0].session_id == "test"

    def test_empty_text_yields_nothing(self):
        src = TextSource(text="   \n  ", label="test")
        assert list(src.iter_chunks()) == []

    def test_long_text_splits(self):
        long = "word " * 1000  # ~5000 chars
        src = TextSource(text=long)
        chunks = list(src.iter_chunks())
        assert len(chunks) >= 2
        assert all(c.source_type == "text" for c in chunks)
        assert all(c.chunk_index == i for i, c in enumerate(chunks))

    def test_chunk_indices_sequential(self):
        long = ("paragraph " * 400 + "\n\n") * 5
        src = TextSource(text=long)
        chunks = list(src.iter_chunks())
        for i, c in enumerate(chunks):
            assert c.chunk_index == i


# ---------------------------------------------------------------------------
# FileSource
# ---------------------------------------------------------------------------

class TestFileSource:
    def test_reads_text_file(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
            f.write("# Memory Log\n\nAndrew prefers dark mode.\n\nUses tmux.")
            path = f.name
        try:
            src = FileSource(path=path)
            chunks = list(src.iter_chunks())
            assert len(chunks) >= 1
            combined = " ".join(c.content for c in chunks)
            assert "dark mode" in combined
        finally:
            os.unlink(path)

    def test_empty_file_yields_nothing(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
            f.write("   \n  ")
            path = f.name
        try:
            src = FileSource(path=path)
            assert list(src.iter_chunks()) == []
        finally:
            os.unlink(path)

    def test_nonexistent_file_raises(self):
        src = FileSource(path="/tmp/no-such-file-xyz.md")
        with pytest.raises(FileNotFoundError):
            list(src.iter_chunks())

    def test_session_id_is_filename(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False,
                                          prefix="memory-test-") as f:
            f.write("Some content here.")
            path = f.name
        try:
            src = FileSource(path=path)
            chunks = list(src.iter_chunks())
            assert chunks[0].session_id == Path(path).name
        finally:
            os.unlink(path)

    def test_source_type_is_file(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
            f.write("Hello file")
            path = f.name
        try:
            src = FileSource(path=path)
            chunks = list(src.iter_chunks())
            assert all(c.source_type == "file" for c in chunks)
        finally:
            os.unlink(path)


# ---------------------------------------------------------------------------
# SessionLogSource
# ---------------------------------------------------------------------------

class TestSessionLogSource:
    def _write_session(self, tmpdir: Path, name: str, messages: list[dict]) -> Path:
        path = tmpdir / f"{name}.jsonl"
        path.write_text(_make_session_jsonl(messages))
        return path

    def test_reads_messages_no_filter(self, tmp_path):
        self._write_session(tmp_path, "session-001", [
            {"role": "user", "text": "Hello", "ts": "2026-03-13T10:00:00Z"},
            {"role": "assistant", "text": "Hi there!", "ts": "2026-03-13T10:00:05Z"},
        ])
        src = SessionLogSource(sessions_dir=str(tmp_path))
        chunks = list(src.iter_chunks())
        assert len(chunks) >= 1
        combined = " ".join(c.content for c in chunks)
        assert "Hello" in combined
        assert "Hi there" in combined

    def test_time_range_filter_excludes_old(self, tmp_path):
        self._write_session(tmp_path, "old-session", [
            {"role": "user", "text": "Old message", "ts": "2026-03-12T08:00:00Z"},
        ])
        self._write_session(tmp_path, "new-session", [
            {"role": "user", "text": "New message", "ts": "2026-03-13T10:00:00Z"},
        ])
        from_ts = datetime(2026, 3, 13, 0, 0, 0, tzinfo=timezone.utc)
        src = SessionLogSource(sessions_dir=str(tmp_path), from_ts=from_ts)
        chunks = list(src.iter_chunks())
        combined = " ".join(c.content for c in chunks)
        assert "New message" in combined
        assert "Old message" not in combined

    def test_time_range_filter_to_ts(self, tmp_path):
        self._write_session(tmp_path, "s1", [
            {"role": "user", "text": "Early msg", "ts": "2026-03-13T06:00:00Z"},
            {"role": "user", "text": "Late msg", "ts": "2026-03-13T18:00:00Z"},
        ])
        to_ts = datetime(2026, 3, 13, 12, 0, 0, tzinfo=timezone.utc)
        src = SessionLogSource(sessions_dir=str(tmp_path), to_ts=to_ts)
        chunks = list(src.iter_chunks())
        combined = " ".join(c.content for c in chunks)
        assert "Early msg" in combined
        assert "Late msg" not in combined

    def test_empty_sessions_dir_yields_nothing(self, tmp_path):
        src = SessionLogSource(sessions_dir=str(tmp_path))
        assert list(src.iter_chunks()) == []

    def test_nonexistent_dir_yields_nothing(self):
        src = SessionLogSource(sessions_dir="/tmp/no-such-dir-xyz-abc")
        assert list(src.iter_chunks()) == []

    def test_malformed_jsonl_skipped(self, tmp_path):
        path = tmp_path / "bad.jsonl"
        path.write_text('{"type":"session"}\nnot-json\n{"type":"message","message":{"role":"user","content":"ok"}}\n')
        src = SessionLogSource(sessions_dir=str(tmp_path))
        # Should not raise, bad lines silently skipped
        chunks = list(src.iter_chunks())
        assert isinstance(chunks, list)

    def test_session_id_is_uuid_filename(self, tmp_path):
        self._write_session(tmp_path, "abc-def-123", [
            {"role": "user", "text": "hello", "ts": "2026-03-13T10:00:00Z"},
        ])
        src = SessionLogSource(sessions_dir=str(tmp_path))
        chunks = list(src.iter_chunks())
        assert chunks[0].session_id == "abc-def-123"

    def test_content_includes_role_labels(self, tmp_path):
        self._write_session(tmp_path, "s1", [
            {"role": "user", "text": "What is 2+2?", "ts": "2026-03-13T10:00:00Z"},
            {"role": "assistant", "text": "It is 4.", "ts": "2026-03-13T10:00:01Z"},
        ])
        src = SessionLogSource(sessions_dir=str(tmp_path))
        chunks = list(src.iter_chunks())
        combined = "\n".join(c.content for c in chunks)
        assert "User:" in combined
        assert "Assistant:" in combined

    def test_epoch_ms_timestamp_parsed(self, tmp_path):
        # Epoch ms in the message.timestamp field
        ts_ms = 1771861737889  # 2026-02-23T...
        path = tmp_path / "epoch-ts.jsonl"
        path.write_text(json.dumps({
            "type": "message",
            "id": "m1",
            "timestamp": "2026-02-23T15:48:57.878Z",
            "message": {
                "role": "user",
                "content": [{"type": "text", "text": "epoch test"}],
                "timestamp": ts_ms,
            },
        }) + "\n")
        src = SessionLogSource(sessions_dir=str(tmp_path))
        chunks = list(src.iter_chunks())
        # Should parse without error (no time filter, so included)
        assert isinstance(chunks, list)


# ---------------------------------------------------------------------------
# BatchIngestor — dry run mode
# ---------------------------------------------------------------------------

class TestBatchIngestorDryRun:
    def _make_chunks(self, n: int = 3) -> list[Chunk]:
        return [
            Chunk(content=f"Content {i}", session_id="test", chunk_index=i, source_type="text")
            for i in range(n)
        ]

    def test_dry_run_no_http_calls(self, tmp_path):
        checkpoint = str(tmp_path / "checkpoint.json")
        idempotency = IdempotencyStore(state_path=checkpoint)
        ingestor = BatchIngestor(
            url="https://example.com",
            owner_id="test-owner",
            dry_run=True,
            idempotency=idempotency,
        )
        chunks = self._make_chunks(3)
        summary = ingestor.ingest_chunks(chunks, verbose=False)
        assert summary.succeeded == 3
        assert summary.failed == 0
        assert summary.total_chunks == 3

    def test_skips_already_processed(self, tmp_path):
        checkpoint = str(tmp_path / "checkpoint.json")
        idempotency = IdempotencyStore(state_path=checkpoint)
        chunks = self._make_chunks(3)
        # Mark first chunk as already processed
        idempotency.mark_processed(chunks[0].chunk_hash)

        ingestor = BatchIngestor(
            url="https://example.com",
            owner_id="test-owner",
            dry_run=True,
            idempotency=idempotency,
        )
        summary = ingestor.ingest_chunks(chunks, verbose=False)
        assert summary.skipped == 1
        assert summary.succeeded == 2
        assert summary.total_chunks == 3

    def test_dry_run_does_not_mark_processed(self, tmp_path):
        """Dry-run should NOT mutate the idempotency checkpoint."""
        checkpoint = str(tmp_path / "checkpoint.json")
        idempotency = IdempotencyStore(state_path=checkpoint)
        chunks = self._make_chunks(2)

        ingestor = BatchIngestor(
            url="https://example.com",
            owner_id="test-owner",
            dry_run=True,
            idempotency=idempotency,
        )
        ingestor.ingest_chunks(chunks, verbose=False)
        for c in chunks:
            assert not idempotency.is_processed(c.chunk_hash)

    def test_summary_duration_positive(self, tmp_path):
        checkpoint = str(tmp_path / "checkpoint.json")
        idempotency = IdempotencyStore(state_path=checkpoint)
        ingestor = BatchIngestor(
            url="https://example.com",
            owner_id="test-owner",
            dry_run=True,
            idempotency=idempotency,
        )
        summary = ingestor.ingest_chunks(self._make_chunks(1), verbose=False)
        assert summary.duration_s >= 0


# ---------------------------------------------------------------------------
# run_batch_ingest integration (dry run, file source)
# ---------------------------------------------------------------------------

class TestRunBatchIngest:
    def test_file_source_end_to_end_dry_run(self, tmp_path):
        content_file = tmp_path / "memory.md"
        content_file.write_text("# Memory\n\nAndrew likes coffee.\n\nHe uses dark mode.")
        checkpoint = str(tmp_path / "chk.json")

        summary = run_batch_ingest(
            source="file",
            file_path=str(content_file),
            owner="test-owner",
            url="https://example.com",
            dry_run=True,
            verbose=False,
            checkpoint_path=checkpoint,
        )
        assert summary.total_chunks >= 1
        assert summary.succeeded >= 1
        assert summary.failed == 0

    def test_text_source_end_to_end_dry_run(self, tmp_path):
        checkpoint = str(tmp_path / "chk.json")
        summary = run_batch_ingest(
            source="text",
            text="Some text to ingest for testing purposes",
            owner="test-owner",
            url="https://example.com",
            dry_run=True,
            verbose=False,
            checkpoint_path=checkpoint,
        )
        assert summary.total_chunks == 1
        assert summary.succeeded == 1

    def test_invalid_source_raises(self, tmp_path):
        checkpoint = str(tmp_path / "chk.json")
        with pytest.raises(ValueError, match="Unknown source"):
            run_batch_ingest(
                source="foobar",
                owner="test-owner",
                dry_run=True,
                verbose=False,
                checkpoint_path=checkpoint,
            )

    def test_file_source_missing_path_raises(self, tmp_path):
        checkpoint = str(tmp_path / "chk.json")
        with pytest.raises(ValueError, match="--path"):
            run_batch_ingest(
                source="file",
                owner="test-owner",
                dry_run=True,
                verbose=False,
                checkpoint_path=checkpoint,
            )

    def test_text_source_missing_text_raises(self, tmp_path):
        checkpoint = str(tmp_path / "chk.json")
        with pytest.raises(ValueError, match="--text"):
            run_batch_ingest(
                source="text",
                owner="test-owner",
                dry_run=True,
                verbose=False,
                checkpoint_path=checkpoint,
            )

    def test_sessions_source_no_sessions_dir(self, tmp_path):
        checkpoint = str(tmp_path / "chk.json")
        empty_dir = tmp_path / "sessions"
        empty_dir.mkdir()
        # Empty dir = nothing to process, but no error
        summary = run_batch_ingest(
            source="sessions",
            sessions_dir=str(empty_dir),
            owner="test-owner",
            dry_run=True,
            verbose=False,
            checkpoint_path=checkpoint,
        )
        assert summary.total_chunks == 0

    def test_dry_run_never_skips_on_rerun(self, tmp_path):
        """Dry-run doesn't mark processed, so re-runs never skip."""
        checkpoint = str(tmp_path / "chk.json")

        content_file = tmp_path / "mem.md"
        content_file.write_text("First run content here.")
        summary1 = run_batch_ingest(
            source="file",
            file_path=str(content_file),
            owner="o",
            dry_run=True,
            verbose=False,
            checkpoint_path=checkpoint,
        )
        assert summary1.skipped == 0

        # Second dry run — should NOT skip (dry-run doesn't mark processed)
        summary2 = run_batch_ingest(
            source="file",
            file_path=str(content_file),
            owner="o",
            dry_run=True,
            verbose=False,
            checkpoint_path=checkpoint,
        )
        assert summary2.skipped == 0
        assert summary2.succeeded == summary1.succeeded


# ---------------------------------------------------------------------------
# Provenance & session_date tests (R-035)
# ---------------------------------------------------------------------------

class TestFileSourceSessionDate:
    """Test date extraction from filenames and provenance metadata."""

    def test_date_extracted_from_yyyy_mm_dd_filename(self, tmp_path):
        f = tmp_path / "2026-03-12.md"
        f.write_text("Some memory content here.\n")
        src = FileSource(str(f))
        chunks = list(src.iter_chunks())
        assert len(chunks) == 1
        assert chunks[0].metadata["session_date"] == "2026-03-12"

    def test_source_session_id_is_stem(self, tmp_path):
        f = tmp_path / "2026-03-12.md"
        f.write_text("Content.\n")
        src = FileSource(str(f))
        chunks = list(src.iter_chunks())
        assert chunks[0].metadata["source_session_id"] == "2026-03-12"

    def test_no_date_for_non_date_filename(self, tmp_path):
        f = tmp_path / "random-notes.md"
        f.write_text("Some notes.\n")
        src = FileSource(str(f))
        chunks = list(src.iter_chunks())
        assert "session_date" not in chunks[0].metadata

    def test_source_session_id_for_non_date_file(self, tmp_path):
        f = tmp_path / "random-notes.md"
        f.write_text("Some notes.\n")
        src = FileSource(str(f))
        chunks = list(src.iter_chunks())
        assert chunks[0].metadata["source_session_id"] == "random-notes"


class TestTextSourceProvenance:
    """Test TextSource includes source_session_id."""

    def test_text_source_has_source_session_id(self):
        src = TextSource("Some text to ingest", label="my-label")
        chunks = list(src.iter_chunks())
        assert len(chunks) == 1
        assert chunks[0].metadata["source_session_id"] == "my-label"


class TestSessionLogSourceProvenance:
    """Test SessionLogSource includes provenance fields."""

    def test_session_date_from_first_message(self, tmp_path):
        session_dir = tmp_path / "sessions"
        session_dir.mkdir()
        content = _make_session_jsonl([
            {"role": "user", "text": "Hello", "ts": "2026-03-13T10:00:00Z"},
            {"role": "assistant", "text": "Hi there", "ts": "2026-03-13T10:01:00Z"},
        ])
        (session_dir / "abc-123.jsonl").write_text(content)
        src = SessionLogSource(sessions_dir=str(session_dir))
        chunks = list(src.iter_chunks())
        assert len(chunks) >= 1
        assert chunks[0].metadata.get("session_date") == "2026-03-13"

    def test_source_session_id_is_uuid_stem(self, tmp_path):
        session_dir = tmp_path / "sessions"
        session_dir.mkdir()
        content = _make_session_jsonl([
            {"role": "user", "text": "Hello", "ts": "2026-03-13T10:00:00Z"},
        ])
        (session_dir / "abc-123.jsonl").write_text(content)
        src = SessionLogSource(sessions_dir=str(session_dir))
        chunks = list(src.iter_chunks())
        assert chunks[0].metadata["source_session_id"] == "abc-123"


class TestBatchIngestorProvenance:
    """Test that BatchIngestor sends provenance fields in payload."""

    def test_default_entity_fields(self):
        ingestor = BatchIngestor(url="http://localhost:8000", owner_id="test")
        assert ingestor.entity_id == "eva"
        assert ingestor.entity_type == "agent"
        assert ingestor.source_channel == "memory_log"

    def test_custom_entity_fields(self):
        ingestor = BatchIngestor(
            url="http://localhost:8000",
            owner_id="test",
            entity_id="custom-bot",
            entity_type="system",
            source_channel="custom_channel",
        )
        assert ingestor.entity_id == "custom-bot"
        assert ingestor.entity_type == "system"
        assert ingestor.source_channel == "custom_channel"

    @patch("urllib.request.urlopen")
    def test_post_remember_includes_provenance(self, mock_urlopen):
        """Verify the HTTP payload includes all provenance fields."""
        import io

        # Mock response
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps({
            "claims_extracted": 2,
            "claims_stored": 1,
            "session_id": "test",
            "ok": True,
        }).encode()
        mock_resp.__enter__ = MagicMock(return_value=mock_resp)
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        ingestor = BatchIngestor(
            url="http://localhost:8000",
            owner_id="test-owner",
            entity_id="eva",
            entity_type="agent",
        )

        chunk = Chunk(
            content="Eva learned something important.",
            session_id="2026-03-12.md",
            chunk_index=0,
            source_type="file",
            metadata={
                "source_session_id": "2026-03-12",
                "session_date": "2026-03-12",
            },
        )

        result = ingestor._post_remember(chunk)
        assert result.ok

        # Inspect the payload that was sent
        call_args = mock_urlopen.call_args
        request_obj = call_args[0][0]
        payload = json.loads(request_obj.data.decode())

        assert payload["source_type"] == "batch_ingest"
        assert payload["source_session_id"] == "2026-03-12"
        assert payload["source_channel"] == "memory_log"
        assert payload["entity_id"] == "eva"
        assert payload["entity_type"] == "agent"
        assert payload["metadata"]["session_date"] == "2026-03-12"

    @patch("urllib.request.urlopen")
    def test_post_remember_uses_canonical_endpoint(self, mock_urlopen):
        """Verify we POST to /api/v1/memories/remember, not the deprecated /api/v1/remember."""
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps({
            "claims_extracted": 0, "claims_stored": 0, "ok": True,
        }).encode()
        mock_resp.__enter__ = MagicMock(return_value=mock_resp)
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        ingestor = BatchIngestor(url="http://localhost:8000", owner_id="test")
        chunk = Chunk(content="test", session_id="t", chunk_index=0, source_type="text")
        ingestor._post_remember(chunk)

        request_obj = mock_urlopen.call_args[0][0]
        assert "/api/v1/memories/remember" in request_obj.full_url
        assert request_obj.full_url.count("/remember") == 1  # not /remember/remember

    @patch("urllib.request.urlopen")
    def test_session_source_uses_session_log_channel(self, mock_urlopen):
        """Verify chunks from sessions source get source_channel='session_log'."""
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps({
            "claims_extracted": 0, "claims_stored": 0, "ok": True,
        }).encode()
        mock_resp.__enter__ = MagicMock(return_value=mock_resp)
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        ingestor = BatchIngestor(url="http://localhost:8000", owner_id="test")
        chunk = Chunk(
            content="test",
            session_id="abc-uuid",
            chunk_index=0,
            source_type="sessions",
            metadata={"source_session_id": "abc-uuid"},
        )
        ingestor._post_remember(chunk)

        payload = json.loads(mock_urlopen.call_args[0][0].data.decode())
        assert payload["source_channel"] == "session_log"

    @patch("urllib.request.urlopen")
    def test_conversation_no_fake_understood_response(self, mock_urlopen):
        """Verify we don't include the fake 'Understood.' assistant response."""
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps({
            "claims_extracted": 0, "claims_stored": 0, "ok": True,
        }).encode()
        mock_resp.__enter__ = MagicMock(return_value=mock_resp)
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        ingestor = BatchIngestor(url="http://localhost:8000", owner_id="test")
        chunk = Chunk(content="Some content", session_id="t", chunk_index=0, source_type="file")
        ingestor._post_remember(chunk)

        payload = json.loads(mock_urlopen.call_args[0][0].data.decode())
        conv = payload["conversation"]
        # Should have content as user message, no fake "Understood." assistant
        assert len(conv) == 1
        assert conv[0]["role"] == "user"
        assert conv[0]["content"] == "Some content"


class TestRunBatchIngestProvenance:
    """Test that run_batch_ingest passes through entity_id/entity_type/source_channel."""

    def test_entity_fields_passed_to_http_ingestor(self, tmp_path):
        content_file = tmp_path / "2026-03-12.md"
        content_file.write_text("Some memory log content.\n")
        checkpoint = str(tmp_path / "cp.json")

        summary = run_batch_ingest(
            source="file",
            file_path=str(content_file),
            owner="test-owner",
            url="http://fake-cortex:8000",
            dry_run=True,
            verbose=False,
            checkpoint_path=checkpoint,
            entity_id="custom-entity",
            entity_type="system",
            source_channel="custom_chan",
        )
        # Dry run should succeed (no HTTP call made)
        assert summary.succeeded >= 1
