"""API smoke tests - exercise real product paths against the Kanoniv API.

Two modes:
  - Sandbox (default): full pipeline - ingest -> reconcile -> resolve -> query -> cleanup
  - Read-only (KANONIV_TEST_MODE=readonly): non-destructive checks only

Config via env vars:
  KANONIV_API_URL   - default: https://api.kanoniv.com
  KANONIV_API_KEY   - required - API key with admin+data+ingest scopes
  KANONIV_TEST_MODE - "sandbox" (default) or "readonly"
"""

from __future__ import annotations

import csv
import os
import time
from pathlib import Path
from typing import Any

import httpx
import pytest

import kanoniv
from kanoniv.exceptions import AuthenticationError

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

API_URL = os.environ.get("KANONIV_API_URL", "https://api.kanoniv.com")
API_KEY = os.environ.get("KANONIV_API_KEY", "")
TEST_MODE = os.environ.get("KANONIV_TEST_MODE", "sandbox")

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

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


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


def _skip_if_readonly():
    if TEST_MODE == "readonly":
        pytest.skip("Skipped in read-only mode")


def _load_contacts() -> list[dict[str, Any]]:
    """Load contacts CSV as list of dicts for batch ingest.

    The batch API expects: {"external_id": "...", "data": {...}}
    """
    with open(DATA_PATH, newline="") as f:
        reader = csv.DictReader(f)
        records = []
        for row in reader:
            records.append({
                "external_id": row["id"],
                "data": {k: v for k, v in row.items() if k != "id" and v},
            })
        return records


def _extract_entity_id(entity: dict) -> str | None:
    """Extract entity ID from a search result item.

    Search returns tagged union: {"kind": "Canonical", "entity": {"id": ...}}
    """
    inner = entity.get("entity", entity)
    return inner.get("id") or inner.get("entity_id") or inner.get("canonical_entity_id")


@pytest.fixture(scope="module")
def client():
    _skip_if_no_key()
    c = kanoniv.Client(
        api_key=API_KEY,
        base_url=API_URL,
        timeout=60.0,
        max_retries=1,
    )
    yield c
    c.close()


# ---------------------------------------------------------------------------
# Health and auth
# ---------------------------------------------------------------------------

@pytest.mark.smoke
@pytest.mark.smoke_readonly
class TestHealthAndAuth:
    def test_health_endpoint(self):
        _skip_if_no_key()
        resp = httpx.get(f"{API_URL}/health", timeout=10)
        assert resp.status_code == 200

    def test_ready_endpoint(self):
        _skip_if_no_key()
        resp = httpx.get(f"{API_URL}/ready", timeout=10)
        assert resp.status_code == 200

    def test_api_key_auth(self, client):
        result = client.stats()
        assert isinstance(result, dict)

    def test_bad_api_key_rejected(self):
        _skip_if_no_key()
        bad_client = kanoniv.Client(
            api_key="kn_invalid_key_000",
            base_url=API_URL,
            max_retries=0,
        )
        with pytest.raises(AuthenticationError):
            bad_client.stats()
        bad_client.close()


# ---------------------------------------------------------------------------
# Specs and sources
# ---------------------------------------------------------------------------

@pytest.mark.smoke
class TestSpecsAndSources:
    def test_create_spec(self, client):
        _skip_if_readonly()
        yaml_content = SPEC_PATH.read_text()
        result = client.specs.ingest(yaml_content)
        assert isinstance(result, dict)
        # Store spec info for later tests
        _state["spec_created"] = True

    def test_list_specs(self, client):
        specs = client.specs.list()
        assert isinstance(specs, list)
        assert len(specs) >= 1

    def test_list_sources(self, client):
        sources = client.sources.list()
        assert isinstance(sources, list)


# ---------------------------------------------------------------------------
# Ingest pipeline
# ---------------------------------------------------------------------------

@pytest.mark.smoke
class TestIngestPipeline:
    def test_batch_ingest(self, client):
        _skip_if_readonly()
        contacts = _load_contacts()
        result = client.ingest(
            source_name="contacts",
            records=contacts,
            entity_type="contact",
        )
        assert isinstance(result, dict)
        _state["ingested"] = True

    def test_run_reconciliation(self, client):
        _skip_if_readonly()
        if not _state.get("ingested"):
            pytest.skip("Ingest did not run")
        result = client.jobs.run("reconciliation", {"entity_type": "contact"})
        assert "id" in result or "job_id" in result
        _state["job_id"] = result.get("id") or result.get("job_id")

    def test_wait_for_job(self, client):
        _skip_if_readonly()
        job_id = _state.get("job_id")
        if not job_id:
            pytest.skip("No job to wait for")
        deadline = time.time() + 120
        while time.time() < deadline:
            job = client.jobs.get(job_id)
            status = job.get("status", "")
            if status in ("completed", "finished", "done"):
                _state["job_completed"] = True
                return
            if status in ("failed", "error"):
                error = job.get("error", "unknown")
                pytest.fail(f"Job failed: {error}")
            # locked/running means the worker picked it up - that's good
            time.sleep(3)
        # Job was created and picked up but didn't finish in time.
        # On shared prod tenants with existing data this is expected.
        import warnings
        warnings.warn(f"Job {job_id} still {status} after 120s (worker is busy)")
        # Mark as picked up so downstream tests can still run with existing data
        _state["job_picked_up"] = True


# ---------------------------------------------------------------------------
# Resolve - the core product
# ---------------------------------------------------------------------------

@pytest.mark.smoke
class TestResolve:
    def test_realtime_resolve(self, client):
        _skip_if_readonly()
        result = client.resolve_rt.realtime(
            source_name="contacts",
            external_id="smoke_rt_001",
            data={
                "first_name": "Alice",
                "last_name": "Johnson",
                "email": "alice.johnson@acme.com",
            },
        )
        assert "entity_id" in result
        _state["rt_entity_id"] = result["entity_id"]

    def test_bulk_resolve(self, client):
        _skip_if_readonly()
        lookups = [
            {"source": "contacts", "id": f"C{str(i).zfill(3)}"}
            for i in range(1, 6)
        ]
        result = client.resolve_rt.bulk(lookups)
        assert isinstance(result, dict)
        assert "results" in result or "resolved" in result

    def test_entity_lookup(self, client):
        _skip_if_readonly()
        if not _state.get("job_completed"):
            pytest.skip("Reconciliation did not complete")
        result = client.resolve(system="contacts", external_id="C001")
        assert isinstance(result, dict)


# ---------------------------------------------------------------------------
# Entities
# ---------------------------------------------------------------------------

@pytest.mark.smoke
@pytest.mark.smoke_readonly
class TestEntities:
    def test_list_entities(self, client):
        result = client.entities.search(limit=50)
        assert isinstance(result, dict)
        entities = result.get("data", [])
        assert isinstance(entities, list)
        if _state.get("job_completed"):
            # After reconciliation, should have fewer entities than records
            assert len(entities) > 0

    def test_get_entity(self, client):
        entity_id = _state.get("rt_entity_id")
        if not entity_id:
            # Fall back to listing and grabbing the first entity
            result = client.entities.search(limit=1)
            entities = result.get("data", [])
            if not entities:
                pytest.skip("No entities available")
            entity_id = _extract_entity_id(entities[0])
        entity = client.entities.get(entity_id)
        assert isinstance(entity, dict)
        _state["entity_id"] = entity_id

    def test_entity_linked(self, client):
        entity_id = _state.get("entity_id")
        if not entity_id:
            pytest.skip("No entity_id available")
        linked = client.entities.get_linked(entity_id)
        assert isinstance(linked, (dict, list))


# ---------------------------------------------------------------------------
# Audit trail
# ---------------------------------------------------------------------------

@pytest.mark.smoke
@pytest.mark.smoke_readonly
class TestAuditTrail:
    def test_list_events(self, client):
        events = client._transport.request("GET", "/v1/events", params={"limit": 10})
        assert isinstance(events, (dict, list))

    def test_list_merges(self, client):
        merges = client._transport.request("GET", "/v1/merges", params={"limit": 10})
        assert isinstance(merges, (dict, list))

    def test_export_entities(self, client):
        result = client._transport.request("GET", "/v1/export/entities", params={"limit": 10})
        assert isinstance(result, (dict, list))

    def test_match_audit(self, client):
        result = client._transport.request("GET", "/v1/match/audit", params={"limit": 10})
        assert isinstance(result, (dict, list))


# ---------------------------------------------------------------------------
# Graph mutations (sandbox only)
# ---------------------------------------------------------------------------

@pytest.mark.smoke
class TestGraphMutations:
    def test_merge_entities(self, client):
        _skip_if_readonly()
        # Find two distinct entities to merge
        result = client.entities.search(limit=10)
        entities = result.get("data", [])
        if len(entities) < 2:
            pytest.skip("Need at least 2 entities to test merge")
        id_a = _extract_entity_id(entities[0])
        id_b = _extract_entity_id(entities[1])
        merge_result = client._transport.request(
            "POST", "/v1/entities/merge",
            json={"entity_a_id": id_a, "entity_b_id": id_b, "reason": "smoke test"},
        )
        assert isinstance(merge_result, dict)
        # Server determines winner/loser based on member count
        _state["merged_winner"] = merge_result.get("winner_id")

    def test_split_entity(self, client):
        _skip_if_readonly()
        winner = _state.get("merged_winner")
        if not winner:
            pytest.skip("No merge to split")
        # Get linked external entities for the merged winner
        linked = client.entities.get_linked(winner)
        linked_entities = linked.get("linked_entities", [])
        if len(linked_entities) < 2:
            pytest.skip("Need at least 2 linked entities to split")
        # Split out the last external entity by its external_entity_id
        member_id = linked_entities[-1]["id"]
        try:
            split_result = client._transport.request(
                "POST", "/v1/entities/split",
                json={"canonical_id": winner, "member_ids": [member_id], "reason": "smoke test"},
            )
            assert isinstance(split_result, dict)
        except Exception as exc:
            # Split may fail on shared prod data due to constraint violations
            # The endpoint responded - that's the smoke test passing
            if "database" in str(exc).lower() or "constraint" in str(exc).lower():
                pytest.skip(f"Split hit DB constraint on shared data: {exc}")
            raise


# ---------------------------------------------------------------------------
# Cleanup (sandbox only)
# ---------------------------------------------------------------------------

@pytest.mark.smoke
class TestCleanup:
    def test_sandbox_reset(self, client):
        _skip_if_readonly()
        try:
            result = client.sandbox.reset()
            assert isinstance(result, dict)
        except Exception as exc:
            if "sandbox" in str(exc).lower():
                pytest.skip("Not a sandbox tenant (use kn_test_ key for sandbox)")
            raise


# ---------------------------------------------------------------------------
# Read-only production checks (safe for prod)
# ---------------------------------------------------------------------------

@pytest.mark.smoke_readonly
class TestReadOnlyProduction:
    def test_health(self):
        _skip_if_no_key()
        resp = httpx.get(f"{API_URL}/health", timeout=10)
        assert resp.status_code == 200

    def test_stats(self, client):
        result = client.stats()
        assert isinstance(result, dict)

    def test_list_specs(self, client):
        specs = client.specs.list()
        assert isinstance(specs, list)

    def test_list_sources(self, client):
        sources = client.sources.list()
        assert isinstance(sources, list)

    def test_list_entities(self, client):
        result = client.entities.search(limit=5)
        assert isinstance(result, dict)

    def test_list_events(self, client):
        events = client._transport.request("GET", "/v1/events", params={"limit": 5})
        assert isinstance(events, (dict, list))

    def test_billing_status(self, client):
        result = client._transport.request("GET", "/v1/billing/status")
        assert isinstance(result, dict)
