"""Tests for Task 5.8 — Export Engine.

Covers:
- ExportRegistry: register, duplicate raises, replace, build_row (transforms)
- Services: create_export_job (happy path + unknown entity type),
  cancel_export_job (PENDING→FAILED, non-PENDING guard)
- Celery task: process_export_job (CSV/JSON serialisation, idempotency,
  unknown entity type, store_file integration)
- API: POST /export/{entity_type}/, GET /export/jobs/,
  GET /export/jobs/{id}/status/, GET /export/jobs/{id}/download/,
  POST /export/jobs/{id}/cancel/
"""

from __future__ import annotations

import io
import json
from unittest.mock import MagicMock, patch

import pytest
from django.test import Client

from simorgh.apps.platform_core.export_registry import (
    ColumnExportSpec,
    ExportRegistry,
    ExportSpec,
    export_registry,
)
from simorgh.apps.platform_core.models import ExportJob, ExportJobStatus, ExportFormat
from simorgh.apps.platform_core.services import (
    PlatformCoreError,
    cancel_export_job,
    create_export_job,
)
from tests.factories import AdminUserFactory, TenantFactory, UserFactory

pytestmark = pytest.mark.django_db

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

ENTITY_TYPE = "test.widget_export"


def _make_spec(queryset_fn=None, row_fn=None):
    """Build a minimal ExportSpec for ENTITY_TYPE."""
    if queryset_fn is None:
        queryset_fn = MagicMock(return_value=[])
    if row_fn is None:
        row_fn = lambda obj: {"name": str(obj), "score": 42}  # noqa: E731
    return ExportSpec(
        entity_type=ENTITY_TYPE,
        columns=[
            ColumnExportSpec(key="name", label="Name"),
            ColumnExportSpec(key="score", label="Score"),
        ],
        queryset_fn=queryset_fn,
        row_fn=row_fn,
        display_name="Widget Export",
    )


def _make_org_node(tenant):
    from simorgh.apps.organizations.services import create_node

    return create_node(tenant_id=tenant.pk, name="HQ-Export")


def _make_file_metadata(tenant, org_node):
    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/export.csv",
        filename="export.csv",
        content_type="text/csv",
        size_bytes=200,
        checksum_sha256="b" * 64,
        upload_status="ready",
    )


# ---------------------------------------------------------------------------
# ExportRegistry — unit tests
# ---------------------------------------------------------------------------


class TestExportRegistry:
    """Tests for ExportRegistry using a fresh (isolated) instance."""

    def setup_method(self):
        self.registry = ExportRegistry()
        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)
        new_spec = _make_spec()
        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_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()

    def test_all_specs(self):
        self.registry.register(self.spec)
        assert self.spec in self.registry.all_specs()

    def test_build_row_no_transform(self):
        obj = MagicMock()
        spec = _make_spec(row_fn=lambda o: {"name": "Alice", "score": 99})
        self.registry.register(spec)
        row = self.registry.build_row(spec, obj)
        assert row == {"name": "Alice", "score": 99}

    def test_build_row_with_transform(self):
        transform = lambda val, obj: val.upper()  # noqa: E731
        spec = ExportSpec(
            entity_type=ENTITY_TYPE,
            columns=[
                ColumnExportSpec(key="name", label="Name", transform=transform),
                ColumnExportSpec(key="score", label="Score"),
            ],
            queryset_fn=MagicMock(return_value=[]),
            row_fn=lambda o: {"name": "alice", "score": 10},
        )
        self.registry.register(spec)
        row = self.registry.build_row(spec, object())
        assert row["name"] == "ALICE"
        assert row["score"] == 10

    def test_column_labels(self):
        assert self.spec.column_labels() == ["Name", "Score"]

    def test_column_keys(self):
        assert self.spec.column_keys() == ["name", "score"]


# ---------------------------------------------------------------------------
# Services — create / cancel
# ---------------------------------------------------------------------------


@pytest.mark.django_db
class TestExportJobServices:
    @pytest.fixture(autouse=True)
    def setup(self, db):
        self.tenant = TenantFactory()
        self.org_node = _make_org_node(self.tenant)
        self.user = UserFactory()
        self._spec = _make_spec()
        export_registry.replace(self._spec)

    def test_create_export_job_creates_record(self):
        job = create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            actor=self.user,
            enqueue=False,
        )
        assert job.pk is not None
        assert job.status == ExportJobStatus.PENDING
        assert job.entity_type == ENTITY_TYPE
        assert job.created_by == self.user
        assert job.format == ExportFormat.CSV

    def test_create_export_job_custom_format_and_filters(self):
        job = create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            fmt=ExportFormat.JSON,
            filters={"active": True},
            enqueue=False,
        )
        assert job.format == ExportFormat.JSON
        assert job.filters == {"active": True}

    def test_create_export_job_unknown_entity_type_raises(self):
        with pytest.raises(PlatformCoreError, match="No exporter registered"):
            create_export_job(
                tenant_id=self.tenant.pk,
                organization_node_id=self.org_node.pk,
                entity_type="unknown.type",
                enqueue=False,
            )

    def test_cancel_pending_job(self):
        job = create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            enqueue=False,
        )
        result = cancel_export_job(job, actor=self.user)
        assert result.status == ExportJobStatus.FAILED
        assert "cancelled" in result.error_message.lower()

    def test_cancel_done_job_raises(self):
        job = create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            enqueue=False,
        )
        ExportJob.objects.filter(pk=job.pk).update(status=ExportJobStatus.DONE)
        job.refresh_from_db()
        with pytest.raises(PlatformCoreError, match="Cannot cancel"):
            cancel_export_job(job)

    def test_cancel_processing_job_raises(self):
        job = create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            enqueue=False,
        )
        ExportJob.objects.filter(pk=job.pk).update(status=ExportJobStatus.PROCESSING)
        job.refresh_from_db()
        with pytest.raises(PlatformCoreError, match="Cannot cancel"):
            cancel_export_job(job)


# ---------------------------------------------------------------------------
# Celery task — process_export_job (synchronous, bypassing broker)
# ---------------------------------------------------------------------------


@pytest.mark.django_db
class TestProcessExportJobTask:
    @pytest.fixture(autouse=True)
    def setup(self, db):
        self.tenant = TenantFactory()
        self.org_node = _make_org_node(self.tenant)
        self.user = UserFactory()

        self.fake_objects = [
            MagicMock(name="obj1"),
            MagicMock(name="obj2"),
        ]
        counter = [0]

        def row_fn(obj):
            counter[0] += 1
            return {"name": f"Item {counter[0]}", "score": counter[0] * 10}

        self._spec = ExportSpec(
            entity_type=ENTITY_TYPE,
            columns=[
                ColumnExportSpec(key="name", label="Name"),
                ColumnExportSpec(key="score", label="Score"),
            ],
            queryset_fn=MagicMock(return_value=self.fake_objects),
            row_fn=row_fn,
        )
        export_registry.replace(self._spec)

    def _create_job(self, fmt=ExportFormat.CSV, status=None):
        job = create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            fmt=fmt,
            actor=self.user,
            enqueue=False,
        )
        if status:
            ExportJob.objects.filter(pk=job.pk).update(status=status)
            job.refresh_from_db()
        return job

    def _mock_store_file(self, tenant, org_node):
        """Return a mock FileMetadata and a patcher for store_file."""
        file_meta = _make_file_metadata(tenant, org_node)
        patcher = patch(
            "simorgh.apps.storage.services.store_file",
            return_value=file_meta,
        )
        return patcher, file_meta

    def test_process_csv_happy_path(self):
        from simorgh.apps.platform_core.tasks import process_export_job

        job = self._create_job(fmt=ExportFormat.CSV)
        patcher, file_meta = self._mock_store_file(self.tenant, self.org_node)

        with patcher:
            process_export_job.apply(args=[job.pk])

        job.refresh_from_db()
        assert job.status == ExportJobStatus.DONE
        assert job.row_count == len(self.fake_objects)
        assert job.file_id == file_meta.pk

    def test_process_json_happy_path(self):
        from simorgh.apps.platform_core.tasks import process_export_job

        job = self._create_job(fmt=ExportFormat.JSON)
        patcher, file_meta = self._mock_store_file(self.tenant, self.org_node)

        with patcher:
            process_export_job.apply(args=[job.pk])

        job.refresh_from_db()
        assert job.status == ExportJobStatus.DONE
        assert job.row_count == 2

    def test_process_idempotent_done_job(self):
        from simorgh.apps.platform_core.tasks import process_export_job

        job = self._create_job(status=ExportJobStatus.DONE)

        with patch("simorgh.apps.storage.services.store_file") as mock_store:
            process_export_job.apply(args=[job.pk])
            mock_store.assert_not_called()

        job.refresh_from_db()
        assert job.status == ExportJobStatus.DONE

    def test_process_idempotent_failed_job(self):
        from simorgh.apps.platform_core.tasks import process_export_job

        job = self._create_job(status=ExportJobStatus.FAILED)

        with patch("simorgh.apps.storage.services.store_file") as mock_store:
            process_export_job.apply(args=[job.pk])
            mock_store.assert_not_called()

        job.refresh_from_db()
        assert job.status == ExportJobStatus.FAILED

    def test_process_unknown_entity_type_marks_failed(self):
        from simorgh.apps.platform_core.tasks import process_export_job

        job = self._create_job()
        export_registry.unregister(ENTITY_TYPE)
        try:
            process_export_job.apply(args=[job.pk])
        finally:
            export_registry.replace(self._spec)

        job.refresh_from_db()
        assert job.status == ExportJobStatus.FAILED
        assert job.error_message != ""

    def test_process_store_file_error_marks_failed(self):
        from simorgh.apps.platform_core.tasks import process_export_job

        job = self._create_job()

        with patch(
            "simorgh.apps.storage.services.store_file",
            side_effect=Exception("Storage unavailable"),
        ):
            process_export_job.apply(args=[job.pk])

        job.refresh_from_db()
        assert job.status == ExportJobStatus.FAILED
        assert "Storage unavailable" in job.error_message


# ---------------------------------------------------------------------------
# API — export endpoints
# ---------------------------------------------------------------------------


@pytest.mark.django_db
class TestExportAPI:
    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.client = client

        from simorgh.apps.memberships.models import Membership
        from simorgh.apps.iam.models import Role

        role = Role.objects.create(tenant=self.tenant, code="admin-exp", name="Admin Export")
        mem = Membership.objects.create(
            tenant=self.tenant,
            organization_node=self.org_node,
            role=role,
            status="active",
        )
        mem.users.add(self.user)

        self._spec = _make_spec()
        export_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, fmt=ExportFormat.CSV):
        return create_export_job(
            tenant_id=self.tenant.pk,
            organization_node_id=self.org_node.pk,
            entity_type=ENTITY_TYPE,
            fmt=fmt,
            enqueue=False,
        )

    # ------------------------------------------------------------------
    # POST /export/{entity_type}/
    # ------------------------------------------------------------------

    def test_export_create_returns_201(self):
        with patch("simorgh.apps.platform_core.tasks.process_export_job") as t:
            t.delay = MagicMock()
            resp = self.client.post(
                f"{self.BASE}/export/{ENTITY_TYPE}/",
                data={"format": "csv", "filters": {}},
                content_type="application/json",
                **self._headers(),
            )
        assert resp.status_code == 201
        body = resp.json()
        assert body["entity_type"] == ENTITY_TYPE
        assert body["status"] == ExportJobStatus.PENDING

    def test_export_create_unknown_entity_returns_400(self):
        resp = self.client.post(
            f"{self.BASE}/export/unknown.entity/",
            data={"format": "csv"},
            content_type="application/json",
            **self._headers(),
        )
        assert resp.status_code == 400

    def test_export_create_invalid_format_returns_400(self):
        resp = self.client.post(
            f"{self.BASE}/export/{ENTITY_TYPE}/",
            data={"format": "pdf"},
            content_type="application/json",
            **self._headers(),
        )
        assert resp.status_code == 400

    def test_unauthenticated_create_returns_403(self):
        anon = Client()
        resp = anon.post(
            f"{self.BASE}/export/{ENTITY_TYPE}/",
            data={"format": "csv"},
            content_type="application/json",
            **self._headers(),
        )
        assert resp.status_code in (401, 403)

    # ------------------------------------------------------------------
    # GET /export/jobs/
    # ------------------------------------------------------------------

    def test_export_list_returns_200(self):
        self._create_job_via_service()
        resp = self.client.get(f"{self.BASE}/export/jobs/", **self._headers())
        assert resp.status_code == 200
        assert isinstance(resp.json(), list)
        assert len(resp.json()) >= 1

    def test_export_list_filtered_by_entity_type(self):
        self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/export/jobs/?entity_type={ENTITY_TYPE}",
            **self._headers(),
        )
        assert resp.status_code == 200
        data = resp.json()
        assert all(j["entity_type"] == ENTITY_TYPE for j in data)

    def test_export_list_filtered_by_status(self):
        self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/export/jobs/?status=pending",
            **self._headers(),
        )
        assert resp.status_code == 200

    # ------------------------------------------------------------------
    # GET /export/jobs/{id}/status/
    # ------------------------------------------------------------------

    def test_export_status_returns_200(self):
        job = self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/export/jobs/{job.pk}/status/",
            **self._headers(),
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["id"] == job.pk
        assert body["entity_type"] == ENTITY_TYPE

    def test_export_status_404_for_unknown_job(self):
        resp = self.client.get(
            f"{self.BASE}/export/jobs/99999999/status/",
            **self._headers(),
        )
        assert resp.status_code == 404

    def test_export_status_404_wrong_tenant(self):
        other_tenant = TenantFactory()
        job = self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/export/jobs/{job.pk}/status/",
            HTTP_X_TENANT=other_tenant.slug,
        )
        assert resp.status_code == 404

    # ------------------------------------------------------------------
    # GET /export/jobs/{id}/download/
    # ------------------------------------------------------------------

    def test_export_download_pending_returns_404(self):
        job = self._create_job_via_service()
        resp = self.client.get(
            f"{self.BASE}/export/jobs/{job.pk}/download/",
            **self._headers(),
        )
        # No file yet → 404
        assert resp.status_code == 404

    def test_export_download_done_with_file(self):
        job = self._create_job_via_service()
        file_meta = _make_file_metadata(self.tenant, self.org_node)
        ExportJob.objects.filter(pk=job.pk).update(
            status=ExportJobStatus.DONE, file=file_meta
        )

        # The view attempts a redirect or returns 404 if get_file_url is not available
        resp = self.client.get(
            f"{self.BASE}/export/jobs/{job.pk}/download/",
            **self._headers(),
            follow=False,
        )
        # Should be a redirect (302) or 404 — both are acceptable outcomes
        assert resp.status_code in (302, 404)

    # ------------------------------------------------------------------
    # POST /export/jobs/{id}/cancel/
    # ------------------------------------------------------------------

    def test_export_cancel_pending_job(self):
        job = self._create_job_via_service()
        resp = self.client.post(
            f"{self.BASE}/export/jobs/{job.pk}/cancel/",
            **self._headers(),
        )
        assert resp.status_code == 200
        assert resp.json()["status"] == ExportJobStatus.FAILED

    def test_export_cancel_done_job_returns_400(self):
        job = self._create_job_via_service()
        ExportJob.objects.filter(pk=job.pk).update(status=ExportJobStatus.DONE)
        resp = self.client.post(
            f"{self.BASE}/export/jobs/{job.pk}/cancel/",
            **self._headers(),
        )
        assert resp.status_code == 400

    def test_export_cancel_404_for_unknown_job(self):
        resp = self.client.post(
            f"{self.BASE}/export/jobs/99999999/cancel/",
            **self._headers(),
        )
        assert resp.status_code == 404
