"""
tests/test_batch_owner_required.py — R-155: owner_id must be required in batch ingest.

Verifies:
1. CLI (batch-ingest command) rejects missing --owner / CORTEX_OWNER_ID
2. run_batch_ingest() raises ValueError when owner is None or empty
3. BatchIngestor raises ValueError when owner_id is missing or 'default'
4. LocalBatchIngestor raises ValueError when owner_id is missing or 'default'
5. batch_worker._process_job fails job when owner_id is missing or 'default'
6. API /batch/upload returns 400 when owner_id is missing or 'default'
7. Claims produced by batch carry the correct owner_id (not 'default')
"""
from __future__ import annotations

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

import pytest

from click.testing import CliRunner

from cortex.batch_ingest import (
    BatchIngestor,
    IdempotencyStore,
    LocalBatchIngestor,
    run_batch_ingest,
)


# ---------------------------------------------------------------------------
# 1. run_batch_ingest() — owner validation
# ---------------------------------------------------------------------------

class TestRunBatchIngestOwnerRequired:
    """run_batch_ingest must raise ValueError when owner is absent."""

    def _md_file(self, tmp_path) -> str:
        f = tmp_path / "test.md"
        f.write_text("# Test\n\nSome content here.\n")
        return str(f)

    def test_raises_when_owner_is_none(self, tmp_path):
        with pytest.raises(ValueError, match="owner"):
            run_batch_ingest(
                source="file",
                file_path=self._md_file(tmp_path),
                owner=None,
                url="https://example.com",
                dry_run=True,
                verbose=False,
            )

    def test_raises_when_owner_is_empty_string(self, tmp_path):
        with pytest.raises(ValueError, match="owner"):
            run_batch_ingest(
                source="file",
                file_path=self._md_file(tmp_path),
                owner="",
                url="https://example.com",
                dry_run=True,
                verbose=False,
            )

    def test_raises_when_owner_is_whitespace(self, tmp_path):
        with pytest.raises(ValueError, match="owner"):
            run_batch_ingest(
                source="file",
                file_path=self._md_file(tmp_path),
                owner="   ",
                url="https://example.com",
                dry_run=True,
                verbose=False,
            )

    def test_succeeds_with_explicit_owner(self, tmp_path):
        """Control: an explicit owner does not raise."""
        summary = run_batch_ingest(
            source="file",
            file_path=self._md_file(tmp_path),
            owner="eva-origin",
            url="https://example.com",
            dry_run=True,
            verbose=False,
        )
        assert summary.total_chunks >= 1


# ---------------------------------------------------------------------------
# 2. BatchIngestor — constructor validation
# ---------------------------------------------------------------------------

class TestBatchIngestorOwnerRequired:
    """BatchIngestor must reject missing or 'default' owner_id."""

    def test_raises_when_owner_id_missing(self):
        with pytest.raises((TypeError, ValueError)):
            # Missing positional arg — TypeError from Python
            BatchIngestor(url="https://example.com")  # type: ignore[call-arg]

    def test_raises_when_owner_id_is_default(self):
        with pytest.raises(ValueError, match="default"):
            BatchIngestor(url="https://example.com", owner_id="default")

    def test_raises_when_owner_id_is_empty(self):
        with pytest.raises(ValueError):
            BatchIngestor(url="https://example.com", owner_id="")

    def test_accepts_explicit_owner_id(self):
        ingestor = BatchIngestor(url="https://example.com", owner_id="eva-origin")
        assert ingestor.owner_id == "eva-origin"


# ---------------------------------------------------------------------------
# 3. LocalBatchIngestor — constructor validation
# ---------------------------------------------------------------------------

class TestLocalBatchIngestorOwnerRequired:
    """LocalBatchIngestor must reject missing or 'default' owner_id."""

    def _mock_plugin(self):
        plugin = MagicMock()
        plugin.storage = MagicMock()
        return plugin

    def test_raises_when_owner_id_is_default(self):
        with pytest.raises(ValueError, match="default"):
            LocalBatchIngestor(plugin=self._mock_plugin(), owner_id="default")

    def test_raises_when_owner_id_is_empty(self):
        with pytest.raises(ValueError):
            LocalBatchIngestor(plugin=self._mock_plugin(), owner_id="")

    def test_accepts_explicit_owner_id(self):
        ingestor = LocalBatchIngestor(plugin=self._mock_plugin(), owner_id="andrew-main")
        assert ingestor.owner_id == "andrew-main"


# ---------------------------------------------------------------------------
# 4. CLI — --owner is required
# ---------------------------------------------------------------------------

class TestBatchIngestCLIOwnerRequired:
    """CLI must exit non-zero when --owner is not supplied."""

    def test_missing_owner_exits_nonzero(self, tmp_path):
        from cortex.cli.io_cmds import main

        runner = CliRunner()
        md_file = tmp_path / "test.md"
        md_file.write_text("# Test\n\nContent.\n")

        # No --owner → should fail (Click required option or sys.exit(1))
        result = runner.invoke(main, [
            "batch-ingest",
            "--source", "file",
            "--path", str(md_file),
            "--url", "https://example.com",
            "--dry-run",
        ], catch_exceptions=False)

        assert result.exit_code != 0, (
            f"Expected non-zero exit when --owner missing, got {result.exit_code}.\n"
            f"Output: {result.output}"
        )

    def test_empty_owner_env_exits_nonzero(self, tmp_path):
        """CORTEX_OWNER_ID set to empty string should also fail."""
        from cortex.cli.io_cmds import main

        runner = CliRunner()
        md_file = tmp_path / "test.md"
        md_file.write_text("# Test\n\nContent.\n")

        result = runner.invoke(main, [
            "batch-ingest",
            "--source", "file",
            "--path", str(md_file),
            "--url", "https://example.com",
            "--dry-run",
        ], env={"CORTEX_OWNER_ID": ""}, catch_exceptions=False)

        assert result.exit_code != 0, (
            f"Expected non-zero exit for empty CORTEX_OWNER_ID, got {result.exit_code}.\n"
            f"Output: {result.output}"
        )

    def test_owner_via_env_var_succeeds(self, tmp_path):
        """CORTEX_OWNER_ID env var satisfies the required --owner."""
        from cortex.cli.io_cmds import main

        runner = CliRunner()
        md_file = tmp_path / "test.md"
        md_file.write_text("# Test\n\nContent.\n")

        with patch("cortex.batch_ingest.run_batch_ingest") as mock_ingest:
            from cortex.batch_ingest import BatchSummary
            mock_ingest.return_value = BatchSummary(total_chunks=1, succeeded=1)
            result = runner.invoke(main, [
                "batch-ingest",
                "--source", "file",
                "--path", str(md_file),
                "--url", "https://example.com",
                "--dry-run",
            ], env={"CORTEX_OWNER_ID": "eva-origin"}, catch_exceptions=False)

        assert result.exit_code == 0, (
            f"Expected exit 0 with CORTEX_OWNER_ID set, got {result.exit_code}.\n"
            f"Output: {result.output}"
        )
        # Verify owner was passed through
        call_kwargs = mock_ingest.call_args
        assert call_kwargs is not None
        owner_arg = call_kwargs.kwargs.get("owner") or (
            call_kwargs.args[0] if call_kwargs.args else None
        )
        # It was passed as a kwarg named "owner"
        assert mock_ingest.call_args.kwargs.get("owner") == "eva-origin"


# ---------------------------------------------------------------------------
# 5. batch_worker._process_job — owner_id validation
# ---------------------------------------------------------------------------

class TestBatchWorkerOwnerValidation:
    """BatchWorker._process_job must fail job when owner_id is absent or 'default'."""

    def _make_worker(self):
        from cortex.batch_worker import BatchWorker
        return BatchWorker(db_path="/tmp/test-cortex.db")

    def _make_job(self, config: dict) -> dict:
        return {
            "id": "test-job-id",
            "source_type": "text",
            "config_json": json.dumps(config),
        }

    @pytest.mark.asyncio
    async def test_fails_job_when_owner_id_missing(self):
        worker = self._make_worker()
        job = self._make_job({"text": "some content"})  # no owner_id

        plugin = MagicMock()
        plugin.storage = MagicMock()

        fail_calls = []

        async def fake_fail(job_id, error):
            fail_calls.append((job_id, error))

        worker._fail_job = fake_fail

        await worker._process_job(job, plugin)

        assert len(fail_calls) == 1
        assert "owner_id" in fail_calls[0][1].lower() or "required" in fail_calls[0][1].lower()

    @pytest.mark.asyncio
    async def test_fails_job_when_owner_id_is_default(self):
        worker = self._make_worker()
        job = self._make_job({"text": "some content", "owner_id": "default"})

        plugin = MagicMock()
        plugin.storage = MagicMock()

        fail_calls = []

        async def fake_fail(job_id, error):
            fail_calls.append((job_id, error))

        worker._fail_job = fake_fail

        await worker._process_job(job, plugin)

        assert len(fail_calls) == 1
        assert "default" in fail_calls[0][1].lower() or "owner" in fail_calls[0][1].lower()


# ---------------------------------------------------------------------------
# 6. API /batch/upload — owner_id validation
# ---------------------------------------------------------------------------

class TestBatchAPIOwnerRequired:
    """POST /api/v1/batch/upload must return 400 when owner_id is missing or 'default'."""

    @pytest.fixture
    def client(self, tmp_path):
        """Create a test FastAPI client with the batch router mounted.

        Uses a fresh APIRouter to avoid route duplication when the module-level
        router has already been populated by other tests in the suite.
        """
        pytest.importorskip("fastapi")
        pytest.importorskip("httpx")
        from fastapi import APIRouter, FastAPI
        from fastapi.testclient import TestClient
        from cortex.api.deps import get_plugin
        import cortex.api.routes.batch as batch_module

        # Save original router and replace with a fresh one
        _orig_router = batch_module.router
        batch_module.router = APIRouter()

        app = FastAPI()

        # No-op API key verifier
        def _no_verify():
            pass

        batch_module._make_routes(lambda: _no_verify)
        app.include_router(batch_module.router)

        # Override get_plugin to avoid 503
        async def _fake_plugin():
            return object()
        app.dependency_overrides[get_plugin] = _fake_plugin

        # Patch DB operations to avoid real DB
        batch_module._get_db_path = lambda: str(tmp_path / "test.db")

        yield TestClient(app)

        # Restore original router
        batch_module.router = _orig_router

    def test_missing_owner_id_returns_400(self, client):
        """No owner_id query param → 422 (FastAPI required param) or 400."""
        content = b"# Test\n\nSome content.\n"
        response = client.post(
            "/api/v1/batch/upload",
            files={"file": ("test.md", content, "text/markdown")},
            # No owner_id param
        )
        # FastAPI returns 422 for missing required Query param, or our guard returns 400
        assert response.status_code in (400, 422), (
            f"Expected 400 or 422 for missing owner_id, got {response.status_code}: "
            f"{response.text}"
        )

    def test_default_owner_id_returns_400(self, client, tmp_path):
        """owner_id='default' → 400."""
        import aiosqlite
        import asyncio

        # Create the batch_jobs table so the insert doesn't fail
        db_path = str(tmp_path / "test.db")
        async def _create_table():
            async with aiosqlite.connect(db_path) as db:
                await db.execute("""
                    CREATE TABLE IF NOT EXISTS batch_jobs (
                        id TEXT PRIMARY KEY,
                        status TEXT,
                        source_type TEXT,
                        filename TEXT,
                        config_json TEXT,
                        total_chunks INTEGER DEFAULT 0,
                        processed_chunks INTEGER DEFAULT 0,
                        claims_stored INTEGER DEFAULT 0,
                        errors_json TEXT DEFAULT '[]',
                        file_results_json TEXT DEFAULT '[]',
                        created_at TEXT,
                        updated_at TEXT,
                        completed_at TEXT
                    )
                """)
                await db.commit()
        asyncio.run(_create_table())

        content = b"# Test\n\nSome content.\n"
        response = client.post(
            "/api/v1/batch/upload",
            files={"file": ("test.md", content, "text/markdown")},
            params={"owner_id": "default"},
        )
        assert response.status_code == 400, (
            f"Expected 400 for owner_id='default', got {response.status_code}: "
            f"{response.text}"
        )

    def test_explicit_owner_id_is_stored(self, client, tmp_path):
        """An explicit owner_id is stored in the job config."""
        import aiosqlite
        import asyncio

        db_path = str(tmp_path / "test.db")
        async def _create_table():
            async with aiosqlite.connect(db_path) as db:
                await db.execute("""
                    CREATE TABLE IF NOT EXISTS batch_jobs (
                        id TEXT PRIMARY KEY,
                        status TEXT,
                        source_type TEXT,
                        filename TEXT,
                        config_json TEXT,
                        total_chunks INTEGER DEFAULT 0,
                        processed_chunks INTEGER DEFAULT 0,
                        claims_stored INTEGER DEFAULT 0,
                        errors_json TEXT DEFAULT '[]',
                        file_results_json TEXT DEFAULT '[]',
                        created_at TEXT,
                        updated_at TEXT,
                        completed_at TEXT
                    )
                """)
                await db.commit()
        asyncio.run(_create_table())

        content = b"# Memory\n\nAndrew likes coffee.\n"
        response = client.post(
            "/api/v1/batch/upload",
            files={"file": ("memory.md", content, "text/markdown")},
            params={"owner_id": "eva-origin"},
        )
        assert response.status_code == 201, (
            f"Expected 201, got {response.status_code}: {response.text}"
        )
        data = response.json()
        assert "job_id" in data

        # Verify owner_id stored in config_json
        job_id = data["job_id"]
        async def _read_config():
            async with aiosqlite.connect(db_path) as db:
                db.row_factory = aiosqlite.Row
                async with db.execute(
                    "SELECT config_json FROM batch_jobs WHERE id = ?", (job_id,)
                ) as cur:
                    row = await cur.fetchone()
                    return json.loads(row["config_json"]) if row else None
        config = asyncio.run(_read_config())
        assert config is not None
        assert config["owner_id"] == "eva-origin"


# ---------------------------------------------------------------------------
# 7. Claims carry correct owner_id (integration-style, dry-run)
# ---------------------------------------------------------------------------

class TestBatchClaimsHaveCorrectOwner:
    """Verify that claims produced by batch carry the correct owner_id."""

    def test_ingestor_owner_id_propagated_to_payload(self, tmp_path):
        """BatchIngestor must include the correct owner_id in the POST payload."""
        from cortex.batch_ingest import Chunk

        checkpoint = str(tmp_path / "chk.json")
        idempotency = IdempotencyStore(state_path=checkpoint)

        ingestor = BatchIngestor(
            url="https://example.com",
            owner_id="andrew-main",
            dry_run=False,
            idempotency=idempotency,
        )

        chunk = Chunk(
            content="Andrew prefers dark mode.",
            session_id="test-session",
            chunk_index=0,
            source_type="text",
        )

        captured_payloads = []

        import urllib.request
        class FakeResponse:
            def read(self):
                return json.dumps({"claims_extracted": 1, "claims_stored": 1}).encode()
            def __enter__(self): return self
            def __exit__(self, *a): pass

        def fake_urlopen(req, timeout=None):
            body = json.loads(req.data.decode())
            captured_payloads.append(body)
            return FakeResponse()

        with patch("urllib.request.urlopen", side_effect=fake_urlopen):
            result = ingestor._post_remember(chunk)

        assert result.ok, f"Expected ok=True, got error={result.error}"
        assert len(captured_payloads) == 1
        payload = captured_payloads[0]
        assert payload["owner_id"] == "andrew-main", (
            f"Expected owner_id='andrew-main', got {payload['owner_id']!r}"
        )

    def test_run_batch_ingest_passes_owner_to_ingestor(self, tmp_path):
        """run_batch_ingest must propagate owner to BatchIngestor, not use 'default'."""
        md_file = tmp_path / "memory.md"
        md_file.write_text("# Memory\n\nAndrew uses dark mode.\n\nHe prefers Python.\n")

        constructed_owner_ids = []

        OriginalBatchIngestor = BatchIngestor

        def tracking_ingestor(*args, **kwargs):
            constructed_owner_ids.append(kwargs.get("owner_id") or (args[1] if len(args) > 1 else None))
            inst = OriginalBatchIngestor(*args, **kwargs)
            inst.dry_run = True
            return inst

        with patch("cortex.batch_ingest.BatchIngestor", side_effect=tracking_ingestor):
            run_batch_ingest(
                source="file",
                file_path=str(md_file),
                owner="eva-origin",
                url="https://example.com",
                dry_run=True,
                verbose=False,
            )

        assert len(constructed_owner_ids) == 1
        assert constructed_owner_ids[0] == "eva-origin", (
            f"Expected owner_id='eva-origin', got {constructed_owner_ids[0]!r}"
        )
        assert constructed_owner_ids[0] != "default"
