"""Layer 3 — cloud.reconcile() orchestration: spec upload, batching, polling, result."""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

import httpx
import pytest
import respx

from kanoniv.client.client import KanonivClient
from kanoniv.cloud import CloudReconcileResult, reconcile

from .conftest import (
    API_KEY,
    BASE_URL,
    JOB_ID,
    make_completed_job_response,
    make_job_run_response,
    make_mock_source,
    make_mock_spec,
)


def _setup_happy_path(router: respx.Router, n_entities: int = 1) -> None:
    """Wire up mock routes for a full successful reconcile flow."""
    # Spec ingest
    router.post("/v1/identity/specs").mock(
        return_value=httpx.Response(200, json={"valid": True, "plan_hash": "abc123"}),
    )
    # Entity ingest (any source)
    router.post(url__regex=r"/v1/ingest/webhook/.+").mock(
        return_value=httpx.Response(200, json={"ingested": n_entities}),
    )
    # Job run
    router.post("/v1/jobs/run").mock(
        return_value=httpx.Response(200, json=make_job_run_response()),
    )
    # Job poll → completed
    router.get(f"/v1/jobs/{JOB_ID}").mock(
        return_value=httpx.Response(200, json=make_completed_job_response()),
    )


# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------

class TestReconcileHappyPath:
    """Full successful reconcile flow."""

    @patch("kanoniv.cloud.time.sleep")
    def test_full_flow(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        source = make_mock_source()
        result = reconcile(
            [source], spec, api_key=API_KEY, base_url=BASE_URL,
        )
        assert isinstance(result, CloudReconcileResult)
        assert result.job_id == JOB_ID
        assert result.status == "completed"
        assert result.canonicals_created == 42
        assert result.links_created == 87

    @patch("kanoniv.cloud.time.sleep")
    def test_existing_client(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        source = make_mock_source()
        c = KanonivClient(api_key=API_KEY, base_url=BASE_URL, max_retries=0)
        try:
            result = reconcile([source], spec, client=c)
            assert result._owns_client is False
        finally:
            c.close()

    @patch("kanoniv.cloud.time.sleep")
    def test_owns_client_when_created(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        source = make_mock_source()
        result = reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert result._owns_client is True

    @patch("kanoniv.cloud.time.sleep")
    def test_spec_compile_true(self, mock_sleep: MagicMock, mock_api: respx.Router):
        spec_route = mock_api.post("/v1/identity/specs").mock(
            return_value=httpx.Response(200, json={"valid": True}),
        )
        mock_api.post(url__regex=r"/v1/ingest/webhook/.+").mock(
            return_value=httpx.Response(200, json={"ingested": 1}),
        )
        mock_api.post("/v1/jobs/run").mock(
            return_value=httpx.Response(200, json=make_job_run_response()),
        )
        mock_api.get(f"/v1/jobs/{JOB_ID}").mock(
            return_value=httpx.Response(200, json=make_completed_job_response()),
        )
        spec = make_mock_spec()
        source = make_mock_source()
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        body = spec_route.calls.last.request.read()
        import json
        parsed = json.loads(body)
        assert parsed["compile"] is True

    @patch("kanoniv.cloud.time.sleep")
    def test_entity_type_from_dict(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        spec.entity = {"name": "person"}
        source = make_mock_source()
        result = reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        source.to_entities.assert_called_once_with("person")

    @patch("kanoniv.cloud.time.sleep")
    def test_entity_type_from_string(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec(entity_name="organization")
        source = make_mock_source()
        result = reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        source.to_entities.assert_called_once_with("organization")

    @patch("kanoniv.cloud.time.sleep")
    def test_entity_type_none_defaults(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        spec.entity = None
        source = make_mock_source()
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        source.to_entities.assert_called_once_with("entity")


# ---------------------------------------------------------------------------
# Attribute mapping
# ---------------------------------------------------------------------------

class TestReconcileAttributeMapping:
    """Reverse attribute mapping applied to entity data."""

    @patch("kanoniv.cloud.time.sleep")
    def test_reverse_mapping_applied(self, mock_sleep: MagicMock, mock_api: respx.Router):
        ingest_route = mock_api.post("/v1/ingest/webhook/crm").mock(
            return_value=httpx.Response(200, json={"ingested": 1}),
        )
        mock_api.post("/v1/identity/specs").mock(
            return_value=httpx.Response(200, json={"valid": True}),
        )
        mock_api.post("/v1/jobs/run").mock(
            return_value=httpx.Response(200, json=make_job_run_response()),
        )
        mock_api.get(f"/v1/jobs/{JOB_ID}").mock(
            return_value=httpx.Response(200, json=make_completed_job_response()),
        )
        spec = make_mock_spec(
            sources_config=[
                {"name": "crm", "attributes": {"email": "email_address"}},
            ],
        )
        source = make_mock_source(
            name="crm",
            rows=[{"id": "1", "email_address": "alice@ex.com"}],
        )
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        import json
        body = json.loads(ingest_route.calls.last.request.read())
        assert body[0]["data"]["email"] == "alice@ex.com"

    @patch("kanoniv.cloud.time.sleep")
    def test_unmapped_columns_pass_through(self, mock_sleep: MagicMock, mock_api: respx.Router):
        ingest_route = mock_api.post("/v1/ingest/webhook/crm").mock(
            return_value=httpx.Response(200, json={"ingested": 1}),
        )
        mock_api.post("/v1/identity/specs").mock(
            return_value=httpx.Response(200, json={"valid": True}),
        )
        mock_api.post("/v1/jobs/run").mock(
            return_value=httpx.Response(200, json=make_job_run_response()),
        )
        mock_api.get(f"/v1/jobs/{JOB_ID}").mock(
            return_value=httpx.Response(200, json=make_completed_job_response()),
        )
        spec = make_mock_spec(
            sources_config=[
                {"name": "crm", "attributes": {"email": "email_address"}},
            ],
        )
        source = make_mock_source(
            name="crm",
            rows=[{"id": "1", "email_address": "a@b.com", "phone": "555"}],
        )
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        import json
        body = json.loads(ingest_route.calls.last.request.read())
        assert body[0]["data"]["phone"] == "555"


# ---------------------------------------------------------------------------
# Batching
# ---------------------------------------------------------------------------

class TestReconcileBatching:
    """Entity ingestion respects _BATCH_SIZE=500."""

    def _count_ingest_calls(self, router: respx.Router) -> int:
        return sum(
            1 for call in router.calls
            if "/v1/ingest/webhook/" in str(call.request.url)
            and call.request.method == "POST"
        )

    @patch("kanoniv.cloud.time.sleep")
    def test_500_single_batch(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api, n_entities=500)
        spec = make_mock_spec()
        rows = [{"id": str(i)} for i in range(500)]
        source = make_mock_source(name="crm", rows=rows)
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert self._count_ingest_calls(mock_api) == 1

    @patch("kanoniv.cloud.time.sleep")
    def test_501_two_batches(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api, n_entities=501)
        spec = make_mock_spec()
        rows = [{"id": str(i)} for i in range(501)]
        source = make_mock_source(name="crm", rows=rows)
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert self._count_ingest_calls(mock_api) == 2

    @patch("kanoniv.cloud.time.sleep")
    def test_1200_three_batches(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api, n_entities=1200)
        spec = make_mock_spec()
        rows = [{"id": str(i)} for i in range(1200)]
        source = make_mock_source(name="crm", rows=rows)
        reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert self._count_ingest_calls(mock_api) == 3

    @patch("kanoniv.cloud.time.sleep")
    def test_multi_source_each_batched(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        s1 = make_mock_source(name="crm", rows=[{"id": str(i)} for i in range(501)])
        s2 = make_mock_source(name="erp", rows=[{"id": str(i)} for i in range(501)])
        reconcile([s1, s2], spec, api_key=API_KEY, base_url=BASE_URL)
        assert self._count_ingest_calls(mock_api) == 4  # 2 per source


# ---------------------------------------------------------------------------
# Polling
# ---------------------------------------------------------------------------

class TestReconcilePolling:
    """Job polling transitions."""

    @patch("kanoniv.cloud.time.sleep")
    def test_polls_through_pending_running_completed(
        self, mock_sleep: MagicMock, mock_api: respx.Router,
    ):
        mock_api.post("/v1/identity/specs").mock(
            return_value=httpx.Response(200, json={"valid": True}),
        )
        mock_api.post(url__regex=r"/v1/ingest/webhook/.+").mock(
            return_value=httpx.Response(200, json={"ingested": 1}),
        )
        mock_api.post("/v1/jobs/run").mock(
            return_value=httpx.Response(200, json=make_job_run_response(status="pending")),
        )
        responses = [
            httpx.Response(200, json={"id": JOB_ID, "status": "pending"}),
            httpx.Response(200, json={"id": JOB_ID, "status": "running"}),
            httpx.Response(200, json=make_completed_job_response()),
        ]
        mock_api.get(f"/v1/jobs/{JOB_ID}").mock(side_effect=responses)

        spec = make_mock_spec()
        source = make_mock_source()
        result = reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert result.status == "completed"
        assert mock_sleep.call_count >= 2

    @patch("kanoniv.cloud.time.sleep")
    def test_immediately_completed_skips_polling(
        self, mock_sleep: MagicMock, mock_api: respx.Router,
    ):
        """When jobs.run() returns 'completed', the polling loop is skipped."""
        mock_api.post("/v1/identity/specs").mock(
            return_value=httpx.Response(200, json={"valid": True}),
        )
        mock_api.post(url__regex=r"/v1/ingest/webhook/.+").mock(
            return_value=httpx.Response(200, json={"ingested": 1}),
        )
        completed = make_completed_job_response()
        completed["status"] = "completed"
        mock_api.post("/v1/jobs/run").mock(
            return_value=httpx.Response(200, json=completed),
        )

        spec = make_mock_spec()
        source = make_mock_source()
        result = reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert result.status == "completed"
        mock_sleep.assert_not_called()


# ---------------------------------------------------------------------------
# Client arg validation
# ---------------------------------------------------------------------------

class TestReconcileClientArg:
    """Client/api_key validation in reconcile()."""

    def test_no_client_no_api_key_raises(self):
        spec = make_mock_spec()
        source = make_mock_source()
        with pytest.raises(ValueError, match="client.*api_key"):
            reconcile([source], spec)

    @patch("kanoniv.cloud.time.sleep")
    def test_owns_client_true_when_created(self, mock_sleep: MagicMock, mock_api: respx.Router):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        source = make_mock_source()
        result = reconcile([source], spec, api_key=API_KEY, base_url=BASE_URL)
        assert result._owns_client is True

    @patch("kanoniv.cloud.time.sleep")
    def test_owns_client_false_when_provided(
        self, mock_sleep: MagicMock, mock_api: respx.Router,
    ):
        _setup_happy_path(mock_api)
        spec = make_mock_spec()
        source = make_mock_source()
        c = KanonivClient(api_key=API_KEY, base_url=BASE_URL, max_retries=0)
        try:
            result = reconcile([source], spec, client=c)
            assert result._owns_client is False
        finally:
            c.close()
