"""Infrastructure stress tests - concurrency, determinism, and tenant isolation.

These tests validate infrastructure-level guarantees beyond basic API smoke tests.
They exercise the real API with concurrent clients, duplicate data, and cross-tenant checks.

Config via env vars:
  KANONIV_API_URL   - default: https://api.kanoniv.com
  KANONIV_API_KEY   - required - primary API key (admin+data+ingest)
  KANONIV_API_KEY_2 - optional - second tenant key for isolation tests

Usage:
  KANONIV_API_KEY=kn_... pytest tests/test_stress.py -m stress -v
  KANONIV_API_KEY=kn_... pytest tests/test_stress.py -k deterministic -v
  KANONIV_API_KEY=kn_... KANONIV_API_KEY_2=kn_... pytest tests/test_stress.py -k isolation -v
"""

from __future__ import annotations

import csv
import os
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any

import httpx
import pytest

import kanoniv
from kanoniv.exceptions import KanonivError

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

API_URL = os.environ.get("KANONIV_API_URL", "https://api.kanoniv.com")
API_KEY = os.environ.get("KANONIV_API_KEY", "")
API_KEY_2 = os.environ.get("KANONIV_API_KEY_2", "")

FIXTURES_DIR = Path(__file__).resolve().parents[3] / "examples" / "graph-clustering"
DATA_PATH = FIXTURES_DIR / "data" / "contacts.csv"

# Shared state
_state: dict[str, Any] = {}


def _skip_if_no_key():
    if not API_KEY:
        pytest.skip("KANONIV_API_KEY not set")


def _make_client(api_key: str | None = None) -> kanoniv.Client:
    return kanoniv.Client(
        api_key=api_key or API_KEY,
        base_url=API_URL,
        timeout=60.0,
        max_retries=1,
    )


def _load_contacts() -> list[dict[str, Any]]:
    with open(DATA_PATH, newline="") as f:
        reader = csv.DictReader(f)
        return [
            {
                "external_id": row["id"],
                "data": {k: v for k, v in row.items() if k != "id" and v},
            }
            for row in reader
        ]


def _extract_entity_id(entity: dict) -> str | None:
    inner = entity.get("entity", entity)
    return inner.get("id") or inner.get("entity_id")


def _wait_for_job(client: kanoniv.Client, job_id: str, timeout: int = 180) -> dict:
    deadline = time.time() + timeout
    while time.time() < deadline:
        job = client.jobs.get(job_id)
        status = job.get("status", "")
        if status in ("completed", "finished", "done"):
            return job
        if status in ("failed", "error"):
            raise RuntimeError(f"Job failed: {job.get('error', 'unknown')}")
        time.sleep(3)
    raise TimeoutError(f"Job {job_id} still {status} after {timeout}s")


@pytest.fixture(scope="module")
def client():
    _skip_if_no_key()
    c = _make_client()
    yield c
    c.close()


# ===========================================================================
# TEST 1: Deterministic Replay
# ===========================================================================

@pytest.mark.stress
class TestDeterministicReplay:
    """Ingest the same data twice, verify identical entity graphs.

    This validates the core product promise: same input -> same output.
    If this fails, we're probabilistic, not deterministic.
    """

    def _ingest_and_resolve(self, client: kanoniv.Client, run_label: str) -> dict:
        """Ingest contacts, run reconciliation, return entity state."""
        contacts = _load_contacts()
        # Ingest
        client.ingest(
            source_name=f"replay_{run_label}",
            records=contacts,
            entity_type="contact",
        )
        # Trigger reconciliation
        job = client.jobs.run("reconciliation", {"entity_type": "contact"})
        job_id = job.get("id") or job.get("job_id")
        try:
            _wait_for_job(client, job_id, timeout=180)
        except TimeoutError:
            pytest.skip(f"Reconciliation timed out for {run_label} (worker busy)")
            return {}
        # Collect entity state
        result = client.entities.search(limit=1000, entity_type="contact")
        entities = result.get("data", [])
        stats = client.stats()
        return {
            "entity_count": len(entities),
            "entity_ids": sorted([_extract_entity_id(e) for e in entities]),
            "total_canonical": stats.get("total_canonical_entities"),
            "total_external": stats.get("total_external_entities"),
        }

    def test_same_input_same_output(self, client):
        """Two reconciliation runs on identical data must produce identical graphs."""
        # Run 1
        state_1 = self._ingest_and_resolve(client, "run1")
        if not state_1:
            return

        # Run 2 - same data, should produce same result
        state_2 = self._ingest_and_resolve(client, "run2")
        if not state_2:
            return

        assert state_1["entity_count"] == state_2["entity_count"], (
            f"Entity count changed: {state_1['entity_count']} -> {state_2['entity_count']}"
        )
        assert state_1["total_canonical"] == state_2["total_canonical"], (
            f"Canonical count changed: {state_1['total_canonical']} -> {state_2['total_canonical']}"
        )

    def test_idempotent_ingest(self, client):
        """Re-ingesting identical records should create zero new entities."""
        contacts = _load_contacts()
        # First ingest
        r1 = client.ingest(
            source_name="idem_test",
            records=contacts,
            entity_type="contact",
        )
        # Second ingest - same data
        r2 = client.ingest(
            source_name="idem_test",
            records=contacts,
            entity_type="contact",
        )
        # Content hash dedup should mean zero new records
        new_1 = r1.get("new", r1.get("inserted", 0))
        new_2 = r2.get("new", r2.get("inserted", 0))
        unchanged_2 = r2.get("unchanged", r2.get("skipped", 0))
        # First ingest creates records, second should be all unchanged
        if new_1 > 0:
            assert new_2 == 0 or unchanged_2 > 0, (
                f"Second ingest should be idempotent but created {new_2} new records"
            )

    def test_realtime_resolve_stability(self, client):
        """Resolving the same record twice should return the same entity_id."""
        data = {
            "first_name": "Alice",
            "last_name": "Johnson",
            "email": "alice.johnson@acme.com",
        }
        r1 = client.resolve_rt.realtime(
            source_name="stability_test",
            external_id="stable_001",
            data=data,
        )
        r2 = client.resolve_rt.realtime(
            source_name="stability_test",
            external_id="stable_001",
            data=data,
        )
        assert r1["entity_id"] == r2["entity_id"], (
            f"Same record resolved to different entities: {r1['entity_id']} vs {r2['entity_id']}"
        )


# ===========================================================================
# TEST 2: Tenant Isolation (RLS)
# ===========================================================================

@pytest.mark.stress
class TestTenantIsolation:
    """Verify PostgreSQL RLS prevents any cross-tenant data leaks.

    Uses two separate API keys (different tenants). Ingests identical data
    into both, verifies zero crossover in entities, events, and resolve.

    One leak here = infra death.
    """

    def test_entities_isolated(self, client):
        """Tenant A cannot see Tenant B's entities."""
        if not API_KEY_2:
            pytest.skip("KANONIV_API_KEY_2 not set for isolation test")

        client_a = _make_client(API_KEY)
        client_b = _make_client(API_KEY_2)

        try:
            # Ingest identical data into both tenants
            contacts = _load_contacts()[:5]  # small subset
            client_a.ingest("iso_source", contacts, entity_type="contact")
            client_b.ingest("iso_source", contacts, entity_type="contact")

            # Get entities from each tenant
            entities_a = client_a.entities.search(limit=100)
            entities_b = client_b.entities.search(limit=100)

            ids_a = {_extract_entity_id(e) for e in entities_a.get("data", [])}
            ids_b = {_extract_entity_id(e) for e in entities_b.get("data", [])}

            # Zero overlap in entity IDs
            overlap = ids_a & ids_b
            assert len(overlap) == 0, (
                f"CRITICAL: {len(overlap)} entity IDs leaked across tenants: {overlap}"
            )
        finally:
            client_a.close()
            client_b.close()

    def test_resolve_isolated(self, client):
        """Realtime resolve in Tenant A doesn't affect Tenant B."""
        if not API_KEY_2:
            pytest.skip("KANONIV_API_KEY_2 not set for isolation test")

        client_a = _make_client(API_KEY)
        client_b = _make_client(API_KEY_2)

        try:
            data = {"email": "isolation@test.com", "first_name": "Iso"}
            # Resolve in tenant A
            r_a = client_a.resolve_rt.realtime(
                source_name="iso_resolve",
                external_id="iso_001",
                data=data,
            )
            # Same record in tenant B should create a DIFFERENT entity
            r_b = client_b.resolve_rt.realtime(
                source_name="iso_resolve",
                external_id="iso_001",
                data=data,
            )
            assert r_a["entity_id"] != r_b["entity_id"], (
                f"CRITICAL: Same entity_id across tenants: {r_a['entity_id']}"
            )
        finally:
            client_a.close()
            client_b.close()

    def test_events_isolated(self, client):
        """Tenant A cannot see Tenant B's events."""
        if not API_KEY_2:
            pytest.skip("KANONIV_API_KEY_2 not set for isolation test")

        client_a = _make_client(API_KEY)
        client_b = _make_client(API_KEY_2)

        try:
            events_a = client_a._transport.request("GET", "/v1/events", params={"limit": 100})
            events_b = client_b._transport.request("GET", "/v1/events", params={"limit": 100})

            # Extract event IDs
            def event_ids(events):
                if isinstance(events, list):
                    return {e.get("id") for e in events}
                return {e.get("id") for e in events.get("events", events.get("data", []))}

            ids_a = event_ids(events_a)
            ids_b = event_ids(events_b)
            overlap = ids_a & ids_b - {None}
            assert len(overlap) == 0, (
                f"CRITICAL: {len(overlap)} event IDs leaked across tenants"
            )
        finally:
            client_a.close()
            client_b.close()


# ===========================================================================
# TEST 3: Concurrent Resolve (Agent Storm - Lite)
# ===========================================================================

@pytest.mark.stress
class TestConcurrentResolve:
    """Concurrent clients hitting resolve with overlapping identities.

    Validates:
    - No duplicate entities created for the same identity
    - No 500s under concurrency
    - Idempotent behavior with parallel requests
    - Locking / transactional integrity
    """

    def test_parallel_resolve_same_identity(self, client):
        """10 threads resolving the same record must all get the same entity_id."""
        results: list[dict] = []
        errors: list[Exception] = []
        lock = threading.Lock()

        def resolve_worker(thread_id: int):
            c = _make_client()
            try:
                r = c.resolve_rt.realtime(
                    source_name="concurrent_test",
                    external_id=f"concurrent_{thread_id}",
                    data={
                        "first_name": "Concurrent",
                        "last_name": "Test",
                        "email": "concurrent@stress.test",
                    },
                )
                with lock:
                    results.append(r)
            except Exception as exc:
                with lock:
                    errors.append(exc)
            finally:
                c.close()

        with ThreadPoolExecutor(max_workers=10) as pool:
            futures = [pool.submit(resolve_worker, i) for i in range(10)]
            for f in as_completed(futures):
                f.result()  # re-raise exceptions

        # No 500s
        server_errors = [e for e in errors if getattr(e, "status_code", 0) >= 500]
        assert len(server_errors) == 0, (
            f"{len(server_errors)} server errors under concurrency: {server_errors[:3]}"
        )

        # All should resolve to the same entity (same email = same identity)
        entity_ids = {r["entity_id"] for r in results}
        assert len(entity_ids) == 1, (
            f"Same identity resolved to {len(entity_ids)} different entities: {entity_ids}"
        )

    def test_parallel_resolve_different_identities(self, client):
        """10 threads resolving different records must create distinct entities."""
        results: list[dict] = []
        errors: list[Exception] = []
        lock = threading.Lock()

        def resolve_worker(thread_id: int):
            c = _make_client()
            try:
                r = c.resolve_rt.realtime(
                    source_name="concurrent_test",
                    external_id=f"distinct_{thread_id}",
                    data={
                        "first_name": f"Person{thread_id}",
                        "last_name": f"Unique{thread_id}",
                        "email": f"unique{thread_id}@stress.test",
                    },
                )
                with lock:
                    results.append(r)
            except Exception as exc:
                with lock:
                    errors.append(exc)
            finally:
                c.close()

        with ThreadPoolExecutor(max_workers=10) as pool:
            futures = [pool.submit(resolve_worker, i) for i in range(10)]
            for f in as_completed(futures):
                f.result()

        server_errors = [e for e in errors if getattr(e, "status_code", 0) >= 500]
        assert len(server_errors) == 0, (
            f"{len(server_errors)} server errors under concurrency"
        )

        # Each distinct email should create a distinct entity
        entity_ids = {r["entity_id"] for r in results}
        assert len(entity_ids) == len(results), (
            f"Expected {len(results)} distinct entities, got {len(entity_ids)}"
        )

    def test_parallel_resolve_overlapping_families(self, client):
        """Concurrent resolution of records that share blocking keys.

        Simulates what happens when multiple agents ingest records from
        the same identity family simultaneously. This is the most realistic
        agent storm scenario.
        """
        # Alice family - same email, different sources
        alice_records = [
            {"external_id": f"alice_src{i}", "first_name": "Alice", "last_name": "Storm",
             "email": "alice.storm@test.com", "phone": f"555-{1000+i}"}
            for i in range(5)
        ]
        # Bob family - same phone, different emails
        bob_records = [
            {"external_id": f"bob_src{i}", "first_name": "Bob", "last_name": "Storm",
             "email": f"bob{i}@test.com", "phone": "555-9999"}
            for i in range(5)
        ]
        all_records = alice_records + bob_records

        results: list[dict] = []
        errors: list[Exception] = []
        lock = threading.Lock()

        def resolve_worker(record: dict):
            c = _make_client()
            try:
                r = c.resolve_rt.realtime(
                    source_name="storm_test",
                    external_id=record["external_id"],
                    data={k: v for k, v in record.items() if k != "external_id"},
                )
                with lock:
                    results.append({"record": record, "result": r})
            except Exception as exc:
                with lock:
                    errors.append(exc)
            finally:
                c.close()

        with ThreadPoolExecutor(max_workers=10) as pool:
            futures = [pool.submit(resolve_worker, r) for r in all_records]
            for f in as_completed(futures):
                f.result()

        server_errors = [e for e in errors if getattr(e, "status_code", 0) >= 500]
        assert len(server_errors) == 0, (
            f"{len(server_errors)} server errors in overlapping family resolve"
        )

        # Alice records should all cluster to the same entity (shared email)
        alice_entities = {
            r["result"]["entity_id"]
            for r in results
            if r["record"]["email"] == "alice.storm@test.com"
        }
        # Bob records should cluster too (shared phone)
        bob_entities = {
            r["result"]["entity_id"]
            for r in results
            if r["record"]["last_name"] == "Storm" and r["record"]["email"] != "alice.storm@test.com"
        }

        # Verify no server errors - that's the critical assertion
        # Entity clustering correctness depends on whether realtime resolve
        # does transitive matching, so we just verify consistency
        assert len(results) == 10, (
            f"Expected 10 results, got {len(results)} ({len(errors)} errors)"
        )

    def test_concurrent_merge_same_pair(self, client):
        """Two threads trying to merge the same pair - one should succeed, one should fail gracefully."""
        # Find two entities to merge
        result = client.entities.search(limit=10)
        entities = result.get("data", [])
        if len(entities) < 2:
            pytest.skip("Need at least 2 entities")

        id_a = _extract_entity_id(entities[0])
        id_b = _extract_entity_id(entities[1])

        results: list[dict] = []
        errors: list[Exception] = []
        lock = threading.Lock()

        def merge_worker(thread_id: int):
            c = _make_client()
            try:
                r = c._transport.request(
                    "POST", "/v1/entities/merge",
                    json={"entity_a_id": id_a, "entity_b_id": id_b, "reason": f"storm_{thread_id}"},
                )
                with lock:
                    results.append(r)
            except KanonivError as exc:
                with lock:
                    errors.append(exc)
            finally:
                c.close()

        with ThreadPoolExecutor(max_workers=5) as pool:
            futures = [pool.submit(merge_worker, i) for i in range(5)]
            for f in as_completed(futures):
                f.result()

        # At least one should succeed
        assert len(results) >= 1, "No merge succeeded"
        # No 500s - conflicts should be 404 or 409, not crashes
        server_errors = [e for e in errors if getattr(e, "status_code", 0) >= 500]
        assert len(server_errors) == 0, (
            f"Concurrent merge caused {len(server_errors)} server errors"
        )
        # All successful merges should agree on the winner
        winners = {r.get("winner_id") for r in results}
        assert len(winners) == 1, (
            f"Concurrent merges produced different winners: {winners}"
        )
