"""
Tests for the IP Fabric webhook endpoint.

Endpoint: POST /api/plugins/ipfabric/sync/<pk>/webhook/

The view:
  - Verifies the HMAC-SHA256 signature in the X-IPF-Signature header.
  - Resolves the acting NetBox user from PLUGINS_CONFIG.
  - Silently ignores events that are not (action=discover|load, status=completed,
    test!=true) and returns 200.
  - On a matching event calls IPFabricSync.enqueue_sync_job(adhoc=True, user=…,
    pending_snapshot_id=…) and returns 202.

Also tests the pending_snapshot_id resolution logic inside sync_ipfabric().
"""
import hashlib
import hmac as hmac_lib
import json
from unittest.mock import Mock
from unittest.mock import patch

from core.choices import JobStatusChoices
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from users.models import User

from ipfabric_netbox.choices import IPFabricSnapshotStatusModelChoices
from ipfabric_netbox.choices import IPFabricSourceStatusChoices
from ipfabric_netbox.choices import IPFabricSourceTypeChoices
from ipfabric_netbox.choices import IPFabricSyncStatusChoices
from ipfabric_netbox.jobs import sync_ipfabric
from ipfabric_netbox.models import IPFabricSnapshot
from ipfabric_netbox.models import IPFabricSource
from ipfabric_netbox.models import IPFabricSync

BASE = "/api/plugins/ipfabric"
_WEBHOOK_SECRET = "super-secret-hmac-key-for-testing"
_WEBHOOK_USER = "ipfabric-webhook-bot"
_NEW_SNAPSHOT_ID = "aaaabbbb-cccc-dddd-eeee-ffffffffffff"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _sign(body: bytes, secret: str = _WEBHOOK_SECRET) -> str:
    """Return the hex HMAC-SHA256 digest that IP Fabric would send."""
    return hmac_lib.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()


def _encode(payload: dict) -> bytes:
    return json.dumps(payload).encode("utf-8")


def _valid_payload(**overrides) -> dict:
    base = {
        "type": "snapshot",
        "action": "discover",
        "status": "completed",
        "requester": "cron",
        "snapshot": {"id": _NEW_SNAPSHOT_ID, "name": "New Snapshot"},
        "timestamp": 1711800000,
    }
    base.update(overrides)
    return base


# ---------------------------------------------------------------------------
# Webhook view tests
# ---------------------------------------------------------------------------


class IPFabricWebhookViewTest(TestCase):
    """Tests for POST /api/plugins/ipfabric/sync/<pk>/webhook/"""

    @classmethod
    def setUpTestData(cls):
        cls.webhook_user = User.objects.create_user(
            username=_WEBHOOK_USER,
            password="irrelevant",
        )
        source = IPFabricSource.objects.create(
            name="Webhook Test Source",
            type=IPFabricSourceTypeChoices.LOCAL,
            url="https://ipfabric.example.com",
            status=IPFabricSourceStatusChoices.NEW,
        )
        snapshot = IPFabricSnapshot.objects.create(
            source=source,
            name="Existing Snapshot",
            snapshot_id="existing-snap-id-000",
            data={"version": "6.0.0", "sites": []},
            status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
        )
        cls.sync = IPFabricSync.objects.create(
            name="Webhook Sync",
            snapshot_data=snapshot,
            status=IPFabricSyncStatusChoices.NEW,
        )

    def setUp(self):
        self.client = APIClient()
        self.url = f"{BASE}/sync/{self.sync.pk}/webhook/"

    # --- helpers -----------------------------------------------------------

    def _config(self, secret=_WEBHOOK_SECRET, username=_WEBHOOK_USER):
        return {self.sync.pk: {"secret": secret, "username": username}}

    def _post(
        self, payload: dict, secret=_WEBHOOK_SECRET, signature=None, omit_sig=False
    ):
        body = _encode(payload)
        kwargs = dict(data=body, content_type="application/json")
        if not omit_sig:
            kwargs["HTTP_X_IPF_SIGNATURE"] = signature or _sign(body, secret)
        return self.client.post(self.url, **kwargs)

    # --- authentication / config -------------------------------------------

    def test_no_config_entry_returns_401(self):
        with patch("ipfabric_netbox.api.views.get_plugin_config", return_value={}):
            response = self._post(_valid_payload())
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_missing_signature_header_returns_401(self):
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload(), omit_sig=True)
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_wrong_signature_returns_401(self):
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload(), signature="badc0ffee" * 7)
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_signature_computed_with_wrong_secret_returns_401(self):
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload(), secret="wrong-secret")
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_unknown_username_returns_400(self):
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config",
            return_value=self._config(username="nonexistent-user"),
        ):
            response = self._post(_valid_payload())
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("nonexistent-user", response.data["detail"])

    def test_empty_username_returns_400(self):
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config",
            return_value=self._config(username=""),
        ):
            response = self._post(_valid_payload())
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    def test_invalid_json_body_returns_400(self):
        body = b"this is { not valid json ]["
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self.client.post(
                self.url,
                data=body,
                content_type="application/json",
                HTTP_X_IPF_SIGNATURE=_sign(body),
            )
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("Invalid JSON", response.data["detail"])

    # --- event filtering ---------------------------------------------------

    def test_test_flag_true_returns_200_ignored(self):
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload(test=True))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertIn("ignored", response.data["detail"].lower())

    def test_unsupported_actions_return_200_ignored(self):
        for action_name in ("clone", "delete", "download", "unload"):
            with self.subTest(action=action_name):
                with patch(
                    "ipfabric_netbox.api.views.get_plugin_config",
                    return_value=self._config(),
                ):
                    response = self._post(_valid_payload(action=action_name))
                self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_non_completed_statuses_return_200_ignored(self):
        for event_status in ("started", "failed", "resumed", "stopped"):
            with self.subTest(status=event_status):
                with patch(
                    "ipfabric_netbox.api.views.get_plugin_config",
                    return_value=self._config(),
                ):
                    response = self._post(_valid_payload(status=event_status))
                self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_missing_snapshot_id_returns_400(self):
        payload = _valid_payload()
        payload["snapshot"] = {}  # id key absent
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(payload)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    def test_absent_snapshot_key_returns_400(self):
        payload = _valid_payload()
        del payload["snapshot"]
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(payload)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    # --- successful triggers -----------------------------------------------

    @patch("ipfabric_netbox.api.views.IPFabricSync.enqueue_sync_job")
    def test_discover_completed_returns_202_and_queues_job(self, mock_enqueue):
        mock_enqueue.return_value = Mock(pk=99)
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload(action="discover"))

        self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
        mock_enqueue.assert_called_once_with(
            adhoc=True,
            user=self.webhook_user,
            pending_snapshot_id=_NEW_SNAPSHOT_ID,
        )

    @patch("ipfabric_netbox.api.views.IPFabricSync.enqueue_sync_job")
    def test_load_completed_returns_202_and_queues_job(self, mock_enqueue):
        mock_enqueue.return_value = Mock(pk=100)
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload(action="load"))

        self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
        mock_enqueue.assert_called_once_with(
            adhoc=True,
            user=self.webhook_user,
            pending_snapshot_id=_NEW_SNAPSHOT_ID,
        )

    @patch("ipfabric_netbox.api.views.IPFabricSync.enqueue_sync_job")
    def test_snapshot_id_in_response_detail(self, mock_enqueue):
        mock_enqueue.return_value = Mock(pk=101)
        with patch(
            "ipfabric_netbox.api.views.get_plugin_config", return_value=self._config()
        ):
            response = self._post(_valid_payload())

        self.assertIn(_NEW_SNAPSHOT_ID, response.data["detail"])

    def test_sync_currently_running_returns_409(self):
        IPFabricSync.objects.filter(pk=self.sync.pk).update(
            status=IPFabricSyncStatusChoices.SYNCING
        )
        try:
            with patch(
                "ipfabric_netbox.api.views.get_plugin_config",
                return_value=self._config(),
            ):
                response = self._post(_valid_payload())
            self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
        finally:
            IPFabricSync.objects.filter(pk=self.sync.pk).update(
                status=IPFabricSyncStatusChoices.NEW
            )

    def test_nonexistent_sync_pk_returns_404(self):
        url = f"{BASE}/sync/999999/webhook/"
        body = _encode(_valid_payload())
        config = {999999: {"secret": _WEBHOOK_SECRET, "username": _WEBHOOK_USER}}
        with patch("ipfabric_netbox.api.views.get_plugin_config", return_value=config):
            response = self.client.post(
                url,
                data=body,
                content_type="application/json",
                HTTP_X_IPF_SIGNATURE=_sign(body),
            )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)


# ---------------------------------------------------------------------------
# sync_ipfabric job — pending_snapshot_id resolution
# ---------------------------------------------------------------------------


class SyncIPFabricPendingSnapshotTest(TestCase):
    """Unit tests for the pending_snapshot_id resolution inside sync_ipfabric."""

    @classmethod
    def setUpTestData(cls):
        source = IPFabricSource.objects.create(
            name="Job Test Source",
            type=IPFabricSourceTypeChoices.LOCAL,
            url="https://ipfabric.example.com",
            status=IPFabricSourceStatusChoices.NEW,
        )
        cls.old_snapshot = IPFabricSnapshot.objects.create(
            source=source,
            name="Old Snapshot",
            snapshot_id="old-snap-id-111",
            data={"version": "6.0.0", "sites": []},
            status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
        )
        cls.new_snapshot = IPFabricSnapshot.objects.create(
            source=source,
            name="New Snapshot",
            snapshot_id="new-snap-id-222",
            data={"version": "6.1.0", "sites": []},
            status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
        )
        cls.sync = IPFabricSync.objects.create(
            name="Job Test Sync",
            snapshot_data=cls.old_snapshot,
            status=IPFabricSyncStatusChoices.NEW,
        )

    def _make_job(self):
        mock_job = Mock()
        mock_job.object_id = self.sync.pk
        return mock_job

    def test_pending_snapshot_found_updates_snapshot_data_and_continues(self):
        """When the new snapshot exists, sync.snapshot_data is updated and sync runs."""
        mock_job = self._make_job()
        with self.assertLogs("ipfabric_netbox.jobs", level="INFO") as log_ctx:
            with patch.object(IPFabricSync, "sync") as mock_sync:
                sync_ipfabric(
                    mock_job, pending_snapshot_id=self.new_snapshot.snapshot_id
                )

        self.sync.refresh_from_db()
        self.assertEqual(self.sync.snapshot_data_id, self.new_snapshot.pk)
        mock_sync.assert_called_once()
        mock_job.start.assert_called_once()
        mock_job.terminate.assert_called_once_with()  # success path
        self.assertIn(
            f"redirected to snapshot '{self.new_snapshot.snapshot_id}'",
            log_ctx.output[0],
        )

    def test_pending_snapshot_not_found_terminates_job_with_error(self):
        """When the snapshot cannot be found after source sync, the job errors immediately."""
        mock_job = self._make_job()
        with self.assertLogs("ipfabric_netbox.jobs", level="ERROR") as log_ctx:
            with patch.object(IPFabricSync, "sync") as mock_sync:
                sync_ipfabric(mock_job, pending_snapshot_id="does-not-exist-999")

        mock_job.terminate.assert_called_once_with(
            status=JobStatusChoices.STATUS_ERRORED
        )
        mock_sync.assert_not_called()
        mock_job.start.assert_not_called()
        self.assertIn("does-not-exist-999", log_ctx.output[0])

    def test_no_pending_snapshot_id_runs_normally(self):
        """When pending_snapshot_id is absent the job runs with existing snapshot_data."""
        mock_job = self._make_job()
        with patch.object(IPFabricSync, "sync") as mock_sync:
            sync_ipfabric(mock_job)

        # snapshot_data should be unchanged
        self.sync.refresh_from_db()
        self.assertEqual(self.sync.snapshot_data_id, self.old_snapshot.pk)
        mock_sync.assert_called_once()
