"""Full tests for the Jira service (JiraService.process_webhook_event and friends)."""

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from tests.helpers.factories import WebhookEventFactory

from connectors.jira.scripts.consistency_check import JiraConsistencyChecker
from connectors.jira.service import JiraFetchError
from connectors.jira.transform import transform_remote_links


@pytest.fixture
def jira_env(tmp_path, monkeypatch):
    """Set up a Jira environment with required dirs and env vars."""
    data_dir = tmp_path / "jira_data"
    data_dir.mkdir()
    (data_dir / "issues").mkdir()

    monkeypatch.setenv("JIRA_DOMAIN", "mycompany.atlassian.net")
    monkeypatch.setenv("JIRA_EMAIL", "bot@mycompany.com")
    monkeypatch.setenv("JIRA_API_TOKEN", "test-token-xyz")
    monkeypatch.setenv("JIRA_DATA_DIR", str(data_dir))
    monkeypatch.setenv("JIRA_WEBHOOK_SECRET", "webhook-secret-123")

    return data_dir


def _make_jira_service(jira_env):
    """Create a fresh JiraService with test configuration."""
    from connectors.jira import service as svc
    svc.Config.JIRA_DOMAIN = "mycompany.atlassian.net"
    svc.Config.JIRA_EMAIL = "bot@mycompany.com"
    svc.Config.JIRA_API_TOKEN = "test-token-xyz"
    svc.Config.JIRA_DATA_DIR = jira_env
    svc.Config.JIRA_WEBHOOK_SECRET = "webhook-secret-123"
    svc._jira_service = None
    return svc.JiraService()


def _fake_issue_data(issue_key: str = "TEST-1") -> dict:
    return {
        "key": issue_key,
        "id": "10001",
        "fields": {
            "summary": "Test issue summary",
            "status": {"name": "Open"},
            "issuetype": {"name": "Bug"},
            "attachment": [],
            "comment": {"comments": []},
        },
    }


class TestJiraServiceWebhookProcessing:
    def test_process_issue_updated_calls_fetch_and_save(self, jira_env):
        """process_webhook_event for issue_updated fetches fresh data from API."""
        service = _make_jira_service(jira_env)
        event_data, _, _ = WebhookEventFactory.issue_updated("PROJ-100")
        issue_data = _fake_issue_data("PROJ-100")

        with patch.object(service, "fetch_issue", return_value=issue_data), \
             patch.object(service, "fetch_remote_links", return_value=[]), \
             patch.object(service, "fetch_sla_fields", return_value=None), \
             patch.object(service, "download_all_attachments", return_value=[]), \
             patch("connectors.jira.service.trigger_incremental_transform", return_value=True):
            result = service.process_webhook_event(event_data)

        assert result is True
        saved_file = jira_env / "issues" / "PROJ-100.json"
        assert saved_file.exists()
        with open(saved_file) as f:
            saved = json.load(f)
        assert saved["key"] == "PROJ-100"

    def test_process_issue_deleted_marks_file(self, jira_env):
        """process_webhook_event for issue_deleted marks existing JSON with _deleted_at."""
        service = _make_jira_service(jira_env)

        # Pre-create the issue JSON
        issue_file = jira_env / "issues" / "PROJ-200.json"
        issue_file.write_text(json.dumps({"key": "PROJ-200", "fields": {}}))

        event_data, _, _ = WebhookEventFactory.issue_deleted("PROJ-200")

        with patch("connectors.jira.service.trigger_incremental_transform", return_value=True):
            result = service.process_webhook_event(event_data)

        assert result is True
        with open(issue_file) as f:
            saved = json.load(f)
        assert "_deleted_at" in saved

    def test_process_missing_issue_key_returns_false(self, jira_env):
        """Webhook event without issue key returns False."""
        service = _make_jira_service(jira_env)
        result = service.process_webhook_event({"webhookEvent": "jira:issue_updated"})
        assert result is False

    def test_process_uses_embedded_data_when_fetch_fails(self, jira_env):
        """Falls back to embedded issue data in webhook payload when API fetch fails."""
        service = _make_jira_service(jira_env)
        event_data = {
            "webhookEvent": "jira:issue_updated",
            "issue": {
                "key": "PROJ-300",
                "id": "10003",
                "fields": {
                    "summary": "Embedded issue",
                    "attachment": [],
                    "comment": {"comments": []},
                },
            },
        }

        with patch.object(service, "fetch_issue", return_value=None), \
             patch.object(service, "fetch_remote_links", return_value=[]), \
             patch.object(service, "fetch_sla_fields", return_value=None), \
             patch.object(service, "download_all_attachments", return_value=[]), \
             patch("connectors.jira.service.trigger_incremental_transform", return_value=True):
            result = service.process_webhook_event(event_data)

        assert result is True
        saved_file = jira_env / "issues" / "PROJ-300.json"
        assert saved_file.exists()

    def test_deletion_of_nonexistent_issue_returns_true(self, jira_env):
        """Deleting an issue that has no local file returns True (idempotent)."""
        service = _make_jira_service(jira_env)
        event_data, _, _ = WebhookEventFactory.issue_deleted("PROJ-99999")

        result = service.process_webhook_event(event_data)
        assert result is True

    def test_fetch_issue_returns_none_on_404(self, jira_env):
        """fetch_issue returns None when Jira returns 404."""
        import httpx

        service = _make_jira_service(jira_env)

        mock_response = MagicMock()
        mock_response.status_code = 404
        mock_client = MagicMock()
        mock_client.get.return_value = mock_response
        mock_client.__enter__ = lambda s: mock_client
        mock_client.__exit__ = MagicMock(return_value=False)

        with patch("connectors.jira.service.httpx.Client", return_value=mock_client):
            result = service.fetch_issue("PROJ-MISSING")

        assert result is None

    def test_fetch_issue_returns_data_on_200(self, jira_env):
        """fetch_issue returns parsed JSON on HTTP 200."""
        service = _make_jira_service(jira_env)
        issue_data = _fake_issue_data("PROJ-42")

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = issue_data
        mock_client = MagicMock()
        mock_client.get.return_value = mock_response
        mock_client.__enter__ = lambda s: mock_client
        mock_client.__exit__ = MagicMock(return_value=False)

        with patch("connectors.jira.service.httpx.Client", return_value=mock_client):
            result = service.fetch_issue("PROJ-42")

        assert result is not None
        assert result["key"] == "PROJ-42"

    def test_webhook_event_factory_signature_verification(self):
        """WebhookEventFactory produces correct HMAC-SHA256 signatures."""
        import hashlib
        import hmac

        secret = "test-secret"
        event_data, payload, sig = WebhookEventFactory.issue_updated("TEST-1", secret)

        expected_mac = hmac.new(
            secret.encode("utf-8"), payload, hashlib.sha256
        ).hexdigest()
        assert sig == f"sha256={expected_mac}"


class TestFetchRemoteLinks:
    """fetch_remote_links must raise on auth/server failure to prevent the
    save_issue overlay from writing [] into cached JSON, which downstream
    would interpret as 'delete existing remote_links rows for this issue'."""

    def _mock_http(self, status_code, json_body=None):
        """Build a MagicMock httpx.Client context manager returning a fixed response."""
        response = MagicMock()
        response.status_code = status_code
        response.json.return_value = json_body or []
        client = MagicMock()
        client.get.return_value = response
        client.__enter__ = lambda s: client
        client.__exit__ = MagicMock(return_value=False)
        return client

    def test_returns_list_on_200(self, jira_env):
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(200, [{"id": "1"}])):
            assert service.fetch_remote_links("PROJ-1") == [{"id": "1"}]

    def test_returns_empty_on_404(self, jira_env):
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(404)):
            assert service.fetch_remote_links("PROJ-1") == []

    def test_raises_on_401(self, jira_env):
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(401)):
            with pytest.raises(JiraFetchError, match="auth"):
                service.fetch_remote_links("PROJ-1")

    def test_raises_on_403(self, jira_env):
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(403)):
            with pytest.raises(JiraFetchError, match="auth"):
                service.fetch_remote_links("PROJ-1")

    def test_raises_on_500(self, jira_env):
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(500)):
            with pytest.raises(JiraFetchError, match="server"):
                service.fetch_remote_links("PROJ-1")

    def test_raises_on_429_rate_limit(self, jira_env):
        # 429 in the webhook hot path must raise (not silently return []).
        # A webhook burst hitting Jira's rate limiter is the most likely
        # production scenario; returning [] would re-trigger the wipe bug.
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(429)):
            with pytest.raises(JiraFetchError, match="rate limited"):
                service.fetch_remote_links("PROJ-1")

    def test_raises_on_unexpected_status(self, jira_env):
        # Any non-success/non-404 status raises — covers 400, 405, 418, etc.
        # No silent fall-through.
        service = _make_jira_service(jira_env)
        with patch("connectors.jira.service.httpx.Client",
                   return_value=self._mock_http(418)):
            with pytest.raises(JiraFetchError, match="unexpected status"):
                service.fetch_remote_links("PROJ-1")

    def test_raises_when_unconfigured(self, jira_env):
        """Regression guard for the Devin adversarial-review finding:
        when the Jira service is unconfigured (missing API credentials)
        the fetch must raise JiraFetchError, NOT silently return [].
        Silent [] would overlay an empty list onto cached issue JSON via
        save_issue and wipe existing remote_links parquet rows the next
        time a webhook fires while creds happen to be missing — the
        exact scenario this PR closes for the 401 / 429 / 5xx paths.
        The webhook hot path passes HMAC verification with
        JIRA_WEBHOOK_SECRET (separate from API creds), so a
        missing-creds + webhook-arrives combo is realistic, not
        theoretical."""
        from connectors.jira import service as svc
        # Mirror _make_jira_service except DROP the API credentials so
        # is_configured() returns False.
        svc.Config.JIRA_DOMAIN = ""
        svc.Config.JIRA_EMAIL = ""
        svc.Config.JIRA_API_TOKEN = ""
        svc.Config.JIRA_DATA_DIR = jira_env
        svc.Config.JIRA_WEBHOOK_SECRET = "webhook-secret-123"
        svc._jira_service = None
        service = svc.get_jira_service()
        assert not service.is_configured()
        with pytest.raises(JiraFetchError, match="not configured"):
            service.fetch_remote_links("PROJ-1")

    def test_raises_on_request_error(self, jira_env):
        import httpx
        service = _make_jira_service(jira_env)
        client = MagicMock()
        client.get.side_effect = httpx.RequestError("connection reset")
        client.__enter__ = lambda s: client
        client.__exit__ = MagicMock(return_value=False)
        with patch("connectors.jira.service.httpx.Client", return_value=client):
            with pytest.raises(JiraFetchError, match="connection"):
                service.fetch_remote_links("PROJ-1")


class TestSaveIssueRemoteLinksOverlay:
    """save_issue must NOT set _remote_links when fetch_remote_links raises.
    The absent key is the contract with transform_remote_links: it signals
    'preserve existing rows'. A present-but-empty list would wipe them."""

    def test_sets_remote_links_on_success(self, jira_env):
        service = _make_jira_service(jira_env)
        with patch.object(service, "fetch_remote_links", return_value=[{"id": "rl-1"}]), \
             patch.object(service, "fetch_sla_fields", return_value=None):
            path = service.save_issue(_fake_issue_data("PROJ-1"))
        with open(path) as f:
            data = json.load(f)
        assert data["_remote_links"] == [{"id": "rl-1"}]

    def test_sets_empty_remote_links_on_404(self, jira_env):
        # 404 stays as [] — legitimately means "issue has no remote links".
        service = _make_jira_service(jira_env)
        with patch.object(service, "fetch_remote_links", return_value=[]), \
             patch.object(service, "fetch_sla_fields", return_value=None):
            path = service.save_issue(_fake_issue_data("PROJ-1"))
        with open(path) as f:
            data = json.load(f)
        assert data["_remote_links"] == []

    def test_omits_remote_links_key_on_fetch_error(self, jira_env):
        # When fetch raises, the key MUST be absent — that's the signal
        # to the transform that this isn't fresh data and should be skipped.
        service = _make_jira_service(jira_env)
        with patch.object(service, "fetch_remote_links",
                          side_effect=JiraFetchError("auth")), \
             patch.object(service, "fetch_sla_fields", return_value=None):
            path = service.save_issue(_fake_issue_data("PROJ-1"))
        with open(path) as f:
            data = json.load(f)
        assert "_remote_links" not in data, \
            "Absent key is the contract with transform_remote_links — " \
            "do not change to an empty list."


class TestTransformRemoteLinks:
    """transform_remote_links returns None when _remote_links is absent
    (preserve-existing signal) and [] when present-but-empty (legitimate 'none')."""

    def test_returns_list_when_links_present(self):
        result = transform_remote_links({
            "key": "PROJ-1",
            "_remote_links": [{
                "id": "rl-1",
                "object": {"url": "https://x", "title": "X"},
                "application": {"name": "App", "type": "type"},
            }],
        })
        assert result is not None
        assert len(result) == 1
        assert result[0]["remote_link_id"] == "rl-1"

    def test_returns_empty_list_when_key_present_but_empty(self):
        result = transform_remote_links({"key": "PROJ-1", "_remote_links": []})
        assert result == []

    def test_returns_none_when_key_absent(self):
        # Absent key = save_issue skipped the overlay because fetch failed.
        # Signal to caller: preserve existing parquet rows for this issue.
        result = transform_remote_links({"key": "PROJ-1"})
        assert result is None

    def test_returns_none_when_key_is_explicit_null(self):
        # Defensive: a JSON with `_remote_links: null` (from older buggy code
        # or a manual edit) would otherwise blow up on `for rl in None`.
        # Treat null the same as absent — both mean "no fresh data".
        result = transform_remote_links({"key": "PROJ-1", "_remote_links": None})
        assert result is None


class TestAutoFixThreshold:
    """The auto-fix threshold determines at what gap size we auto-backfill
    vs require manual review. Legacy bumped it from 10 to 20 — small enough
    to stay safe, big enough to absorb a typical SLA-poller hiccup."""

    def test_threshold_is_twenty(self):
        assert JiraConsistencyChecker.AUTO_FIX_THRESHOLD == 20

    def test_alert_level_warning_at_threshold(self):
        # 20 missing should still be WARNING (auto-fix territory), not ERROR.
        config = MagicMock()
        checker = JiraConsistencyChecker(config)
        assert checker.get_alert_level(20) == "WARNING"

    def test_alert_level_error_above_threshold(self):
        config = MagicMock()
        checker = JiraConsistencyChecker(config)
        assert checker.get_alert_level(21) == "ERROR"
