"""Tests for Task 5.7 — Import Engine.

Covers:
- ImportRegistry: register, duplicate raises, validate_row (required, email,
  number, integer, boolean, date)
- Services: create_import_job (happy path + unknown entity type),
  enqueue_import_job (terminal state guard),
  cancel_import_job (PENDING→FAILED, terminal-state guard)
- Celery task: process_import_job (CSV parsing, row error collection,
  idempotency, unknown entity type)
- API: POST /import/{entity_type}/, GET /import/jobs/{id}/status/,
  GET /import/jobs/{id}/errors/, POST /import/jobs/{id}/cancel/,
  GET /import/{entity_type}/template/,  GET /import/jobs/
"""

from __future__ import annotations

import csv
import io
from unittest.mock import MagicMock, patch

import pytest
from django.test import Client

from simorgh.apps.platform_core.import_registry import (
    ColumnSpec,
    ImportRegistry,
    ImportSpec,
    import_registry,
)
from simorgh.apps.platform_core.models import ImportJob, ImportJobStatus
from simorgh.apps.platform_core.services import (
    PlatformCoreError,
    cancel_import_job,
    create_import_job,
    enqueue_import_job,
)
from tests.factories import AdminUserFactory, TenantFactory, UserFactory

pytestmark = pytest.mark.django_db


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

ENTITY_TYPE = "test.widget"


def _make_spec(handler=None):
    if handler is None:
        handler = MagicMock()
    return ImportSpec(
        entity_type=ENTITY_TYPE,
        columns=[
            ColumnSpec(key="name", label="Name", required=True, col_type="text"),
            ColumnSpec(key="email", label="Email", required=True, col_type="email"),
            ColumnSpec(key="age", label="Age", required=False, col_type="integer"),
            ColumnSpec(key="score", label="Score", required=False, col_type="number"),
            ColumnSpec(key="active", label="Active", required=False, col_type="boolean"),
            ColumnSpec(key="joined", label="Joined", required=False, col_type="date"),
        ],
        handler=handler,
        display_name="Widget",
    )


def _make_csv(*rows: dict) -> bytes:
    """Build a CSV bytes payload from a list of dicts (using first dict for headers)."""
    if not rows:
        return b""
    buf = io.StringIO()
    writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
    writer.writeheader()
    writer.writerows(rows)
    return buf.getvalue().encode()


def _make_file_metadata(tenant, org_node):
    """Create a minimal FileMetadata record for the given tenant."""
    from simorgh.apps.storage.models import FileMetadata

    return FileMetadata.objects.create(
        tenant=tenant,
        organization_node=org_node,
        storage_backend="local",
        path=f"tenants/{tenant.pk}/test/import.csv",
        filename="import.csv",
        content_type="text/csv",
        size_bytes=100,
        checksum_sha256="a" * 64,
        upload_status="ready",
    )


def _make_org_node(tenant):
    from simorgh.apps.organizations.services import create_node

    return create_node(tenant_id=tenant.pk, name="HQ")


# ---------------------------------------------------------------------------
# ImportRegistry — unit tests (no DB needed)
# ---------------------------------------------------------------------------


class TestImportRegistry:
    """Tests for ImportRegistry in isolation using a fresh instance."""

    def setup_method(self):
        self.registry = ImportRegistry()
        self.spec = _make_spec()

    def test_register_and_get(self):
        self.registry.register(self.spec)
        assert self.registry.get(ENTITY_TYPE) is self.spec

    def test_register_duplicate_raises(self):
        self.registry.register(self.spec)
        with pytest.raises(ValueError, match="already registered"):
            self.registry.register(self.spec)

    def test_replace_overrides(self):
        self.registry.register(self.spec)
        spec2 = _make_spec()
        spec2.entity_type  # still the same entity_type — replace must work
        new_spec = ImportSpec(
            entity_type=ENTITY_TYPE,
            columns=self.spec.columns,
            handler=MagicMock(),
        )
        self.registry.replace(new_spec)
        assert self.registry.get(ENTITY_TYPE) is new_spec

    def test_unregister(self):
        self.registry.register(self.spec)
        self.registry.unregister(ENTITY_TYPE)
        assert self.registry.get(ENTITY_TYPE) is None

    def test_unregister_noop_if_missing(self):
        self.registry.unregister("nonexistent.type")  # should not raise

    def test_contains(self):
        assert ENTITY_TYPE not in self.registry
        self.registry.register(self.spec)
        assert ENTITY_TYPE in self.registry

    def test_get_or_raise_missing(self):
        with pytest.raises(KeyError):
            self.registry.get_or_raise("missing.type")

    def test_all_entity_types(self):
        self.registry.register(self.spec)
        assert ENTITY_TYPE in self.registry.all_entity_types()

    # ------------------------------------------------------------------
    # validate_row
    # ------------------------------------------------------------------

    def test_validate_row_ok(self):
        self.registry.register(self.spec)
        errors = self.registry.validate_row(
            self.spec,
            {"name": "Alice", "email": "alice@example.com", "age": "30"},
            row_number=1,
        )
        assert errors == []

    def test_validate_row_missing_required(self):
        self.registry.register(self.spec)
        errors = self.registry.validate_row(self.spec, {"name": ""}, row_number=2)
        # 'name' empty + 'email' missing → at least two errors
        assert any("Name" in e for e in errors)
        assert any("Email" in e for e in errors)

    def test_validate_row_bad_email(self):
        errors = self.registry.validate_row(
            self.spec,
            {"name": "Bob", "email": "not-an-email"},
            row_number=3,
        )
        assert any("email" in e.lower() for e in errors)

    def test_validate_row_bad_integer(self):
        errors = self.registry.validate_row(
            self.spec,
            {"name": "Carol", "email": "carol@example.com", "age": "twenty"},
            row_number=4,
        )
        assert any("integer" in e.lower() for e in errors)

    def test_validate_row_bad_number(self):
        errors = self.registry.validate_row(
            self.spec,
            {"name": "Dave", "email": "dave@x.com", "score": "abc"},
            row_number=5,
        )
        assert any("number" in e.lower() for e in errors)

    def test_validate_row_bad_boolean(self):
        errors = self.registry.validate_row(
            self.spec,
            {"name": "Eve", "email": "eve@x.com", "active": "maybe"},
            row_number=6,
        )
        assert any("boolean" in e.lower() for e in errors)

    def test_validate_row_bad_date(self):
        errors = self.registry.validate_row(
            self.spec,
            {"name": "Frank", "email": "frank@x.com", "joined": "31/12/2020"},
            row_number=7,
        )
        assert any("date" in e.lower() for e in errors)

    def test_validate_row_valid_booleans(self):
        for val in ("true", "false", "1", "0", "yes", "no", "True", "YES"):
            errors = self.registry.validate_row(
                self.spec,
                {"name": "x", "email": "x@x.com", "active": val},
                row_number=1,
            )
            assert not any("boolean" in e.lower() for e in errors), f"Failed for {val!r}"


# ---------------------------------------------------------------------------
# Services — create / enqueue / cancel
# ---------------------------------------------------------------------------


@pytest.mark.django_db
class TestImportJobServices:
    @pytest.fixture(autouse=True)
    def setup(self, db):
        self.tenant = TenantFactory()
        self.org_node = _make_org_node(self.tenant)
        self.user = UserFactory()
        self.file = _make_file_metadata(self.tenant, self.org_node)
        # register spec in the global singleton for service layer
        self._spec = _make_spec()
        import_registry.replace(self._spec)  # replace to avoid duplicate error

    def test_create_import_job_creates_record(self):
        job = create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            actor=self.user,
            enqueue=False,
        )
        assert job.pk is not None
        assert job.status == ImportJobStatus.PENDING
        assert job.entity_type == ENTITY_TYPE
        assert job.created_by == self.user

    def test_create_import_job_unknown_entity_type_raises(self):
        with pytest.raises(PlatformCoreError, match="No importer registered"):
            create_import_job(
                tenant_id=self.tenant.pk,
                organization_node_id=self.org_node.pk,
                entity_type="unknown.type",
                file_id=self.file.pk,
                enqueue=False,
            )

    def test_enqueue_import_job_dispatches_task(self):
        job = create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            enqueue=False,
        )
        with patch(
            "simorgh.apps.platform_core.tasks.process_import_job"
        ) as mock_task:
            mock_task.delay = MagicMock()
            enqueue_import_job(job)
            mock_task.delay.assert_called_once_with(job.pk)

    def test_enqueue_terminal_job_raises(self):
        job = create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            enqueue=False,
        )
        ImportJob.objects.filter(pk=job.pk).update(status=ImportJobStatus.DONE)
        job.refresh_from_db()
        with pytest.raises(PlatformCoreError, match="terminal state"):
            enqueue_import_job(job)

    def test_cancel_pending_job(self):
        job = create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            enqueue=False,
        )
        result = cancel_import_job(job, actor=self.user)
        assert result.status == ImportJobStatus.FAILED
        assert result.error_report[0]["errors"][0] == "Job was cancelled by user."

    def test_cancel_done_job_raises(self):
        job = create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            enqueue=False,
        )
        ImportJob.objects.filter(pk=job.pk).update(status=ImportJobStatus.DONE)
        job.refresh_from_db()
        with pytest.raises(PlatformCoreError, match="Cannot cancel"):
            cancel_import_job(job)


# ---------------------------------------------------------------------------
# Celery task — process_import_job (call directly, bypassing broker)
# ---------------------------------------------------------------------------


@pytest.mark.django_db
class TestProcessImportJobTask:
    @pytest.fixture(autouse=True)
    def setup(self, db):
        self.tenant = TenantFactory()
        self.org_node = _make_org_node(self.tenant)
        self.user = UserFactory()
        self.file = _make_file_metadata(self.tenant, self.org_node)
        self.handler = MagicMock()
        self._spec = ImportSpec(
            entity_type=ENTITY_TYPE,
            columns=[
                ColumnSpec(key="name", label="Name", required=True, col_type="text"),
                ColumnSpec(key="email", label="Email", required=True, col_type="email"),
            ],
            handler=self.handler,
        )
        import_registry.replace(self._spec)

    def _create_job(self, status=None):
        job = create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            actor=self.user,
            enqueue=False,
        )
        if status:
            ImportJob.objects.filter(pk=job.pk).update(status=status)
            job.refresh_from_db()
        return job

    def _read_file_side_effect(self, path):
        return _make_csv(
            {"name": "Alice", "email": "alice@example.com"},
            {"name": "Bob", "email": "bob@example.com"},
        )

    def test_process_csv_happy_path(self):
        """Task processes CSV rows and marks job DONE (or FAILED on row errors)."""
        from simorgh.apps.platform_core.tasks import process_import_job

        job = self._create_job()
        file_content = _make_csv(
            {"name": "Alice", "email": "alice@example.com"},
            {"name": "Bob", "email": "bob@example.com"},
        )

        with patch(
            "simorgh.apps.storage.providers.get_provider",
            create=True,
        ) as mock_get_provider:
            provider_inst = MagicMock()
            provider_inst.read.return_value = file_content
            mock_get_provider.return_value = provider_inst

            process_import_job.apply(args=[job.pk])

        job.refresh_from_db()
        assert job.status in (ImportJobStatus.DONE, ImportJobStatus.FAILED)
        assert job.total_rows >= 2
        assert job.processed_rows + job.error_rows == job.total_rows

    def test_process_idempotent_done_job(self):
        """Re-running a DONE job should be a no-op."""
        from simorgh.apps.platform_core.tasks import process_import_job

        job = self._create_job(status=ImportJobStatus.DONE)

        with patch.object(
            ImportJob.objects, "filter", wraps=ImportJob.objects.filter
        ) as mock_filter:
            process_import_job.apply(args=[job.pk])
            # The task should return without mutating the job
        job.refresh_from_db()
        assert job.status == ImportJobStatus.DONE

    def test_process_unknown_entity_type_marks_failed(self):
        """If no spec is registered, job goes to FAILED."""
        from simorgh.apps.platform_core.tasks import process_import_job

        job = self._create_job()
        import_registry.unregister(ENTITY_TYPE)
        try:
            process_import_job.apply(args=[job.pk])
        finally:
            # restore for other tests
            import_registry.replace(self._spec)

        job.refresh_from_db()
        assert job.status == ImportJobStatus.FAILED


# ---------------------------------------------------------------------------
# API — import endpoints
# ---------------------------------------------------------------------------


@pytest.mark.django_db
class TestImportAPI:
    BASE = "/api/v1"

    @pytest.fixture(autouse=True)
    def setup(self, db, client):
        self.tenant = TenantFactory()
        self.org_node = _make_org_node(self.tenant)
        self.user = AdminUserFactory()
        self.file = _make_file_metadata(self.tenant, self.org_node)
        self.client = client

        # Membership so views can determine org node
        from simorgh.apps.memberships.models import Membership
        from simorgh.apps.iam.models import Role

        role = Role.objects.create(tenant=self.tenant, code="admin-imp", name="Admin")
        mem = Membership.objects.create(
            tenant=self.tenant,
            organization_node=self.org_node,
            role=role,
        )
        mem.users.add(self.user)

        # Register spec
        self._spec = ImportSpec(
            entity_type=ENTITY_TYPE,
            columns=[
                ColumnSpec(key="name", label="Name", required=True),
                ColumnSpec(key="email", label="Email", required=True, col_type="email"),
            ],
            handler=MagicMock(),
        )
        import_registry.replace(self._spec)

        self.client.force_login(self.user)

    def _headers(self):
        return {"HTTP_X_TENANT": self.tenant.slug}

    def _create_job_via_service(self):
        return create_import_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            file_id=self.file.pk,
            enqueue=False,
        )

    def test_import_upload_returns_201(self):
        with patch("simorgh.apps.platform_core.tasks.process_import_job") as t:
            t.delay = MagicMock()
            resp = self.client.post(
                f"{self.BASE}/import/{ENTITY_TYPE}/",
                data={"file_id": self.file.pk},
                content_type="application/json",
                **self._headers(),
            )
        assert resp.status_code == 201
        assert resp.json()["entity_type"] == ENTITY_TYPE

    def test_import_upload_unknown_entity_type_returns_400(self):
        resp = self.client.post(
            f"{self.BASE}/import/unknown.entity/",
            data={"file_id": self.file.pk},
            content_type="application/json",
            **self._headers(),
        )
        assert resp.status_code == 400

    def test_import_status_returns_200(self):
        job = self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/import/jobs/{job.pk}/status/",
            **self._headers(),
        )
        assert resp.status_code == 200
        assert resp.json()["id"] == job.pk

    def test_import_status_404_wrong_tenant(self):
        other_tenant = TenantFactory()
        job = self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/import/jobs/{job.pk}/status/",
            HTTP_X_TENANT=other_tenant.slug,
        )
        # Either 404 (tenant not found or job not found in tenant)
        assert resp.status_code in (404,)

    def test_import_errors_returns_200(self):
        job = self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/import/jobs/{job.pk}/errors/",
            **self._headers(),
        )
        assert resp.status_code == 200
        data = resp.json()
        assert "error_report" in data

    def test_import_cancel_pending_job(self):
        job = self._create_job_via_service()
        resp = self.client.post(
            f"{self.BASE}/import/jobs/{job.pk}/cancel/",
            **self._headers(),
        )
        assert resp.status_code == 200
        assert resp.json()["status"] == ImportJobStatus.FAILED

    def test_import_cancel_done_job_returns_400(self):
        job = self._create_job_via_service()
        ImportJob.objects.filter(pk=job.pk).update(status=ImportJobStatus.DONE)
        resp = self.client.post(
            f"{self.BASE}/import/jobs/{job.pk}/cancel/",
            **self._headers(),
        )
        assert resp.status_code == 400

    def test_import_list_returns_200(self):
        self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/import/jobs/",
            **self._headers(),
        )
        assert resp.status_code == 200
        assert isinstance(resp.json(), list)
        assert len(resp.json()) >= 1

    def test_import_template_returns_csv(self):
        resp = self.client.get(
            f"{self.BASE}/import/{ENTITY_TYPE}/template/",
            **self._headers(),
        )
        assert resp.status_code == 200
        assert resp["Content-Type"].startswith("text/csv")
        content = resp.content.decode()
        # Headers should include our column keys
        assert "name" in content
        assert "email" in content

    def test_import_template_unknown_entity_returns_404(self):
        resp = self.client.get(
            f"{self.BASE}/import/unknown.entity/template/",
            **self._headers(),
        )
        assert resp.status_code == 404

    def test_unauthenticated_upload_returns_403(self):
        anon = Client()
        resp = anon.post(
            f"{self.BASE}/import/{ENTITY_TYPE}/",
            data={"file_id": self.file.pk},
            content_type="application/json",
            **self._headers(),
        )
        assert resp.status_code in (401, 403)
