"""
tests/test_aegis_sentry.py — Aegis Sentry webhook route tests.

Coverage:
  1.  HMAC verification — valid signature passes
  2.  HMAC verification — invalid signature returns 401
  3.  HMAC verification — missing header returns 401
  4.  HMAC skipped when no secret configured
  5.  Categorizer — config_error detection
  6.  Categorizer — provider_failure detection
  7.  Categorizer — rate_limit detection
  8.  Categorizer — timeout detection
  9.  Categorizer — deploy_regression (within 30min)
  10. Categorizer — deploy_regression NOT triggered (>30min)
  11. Categorizer — severity=critical for substatus=new
  12. Categorizer — severity=warning for substatus=regressed
  13. Categorizer — severity=info for single occurrence
  14. Dedup — same issue_id twice → second call is no-op
  15. Dedup — severity escalation bypasses dedup
  16. Dedup — dedup window expired → re-announces
  17. Route — issue payload (action=created) → 200 + category
  18. Route — event_alert payload → 200 + category
  19. Route — unknown/unsupported resource → skipped
  20. Route — fleet announce is called with correct payload
  21. Config — aegis_sentry_secret field present on CortexConfig
"""
from __future__ import annotations

import hashlib
import hmac
import json
import os
import tempfile
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
import pytest_asyncio


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

def _sign(body: bytes, secret: str) -> str:
    """Compute HMAC-SHA256 hex signature the same way Sentry does."""
    return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


def _make_issue_payload(
    issue_id: str = "123",
    short_id: str = "CORTEX-1",
    error_class: str = "KeyError",
    error_value: str = "'content'",
    culprit: str = "cortex.core.memory in store_memory",
    substatus: str = "new",
    level: str = "error",
    count: int = 1,
    first_seen: str = "2026-03-18T05:00:00.000Z",
    last_seen: str = "2026-03-18T05:45:00.000Z",
    action: str = "created",
) -> dict:
    return {
        "action": action,
        "data": {
            "issue": {
                "id": issue_id,
                "shortId": short_id,
                "title": f"{error_class}: {error_value}",
                "culprit": culprit,
                "level": level,
                "status": "unresolved",
                "substatus": substatus,
                "count": str(count),
                "firstSeen": first_seen,
                "lastSeen": last_seen,
                "metadata": {
                    "type": error_class,
                    "value": error_value,
                },
                "tags": [
                    {"key": "environment", "value": "production"},
                    {"key": "release", "value": "v1.5.2"},
                ],
                "web_url": f"https://electric-sheep.sentry.io/issues/{issue_id}/",
                "exception": {
                    "values": [
                        {
                            "type": error_class,
                            "value": error_value,
                            "stacktrace": {
                                "frames": [
                                    {
                                        "filename": "cortex/core/memory.py",
                                        "function": "store_memory",
                                        "lineno": 142,
                                        "in_app": True,
                                    }
                                ]
                            },
                        }
                    ]
                },
                "request": {"url": "https://cortex.fly.dev/api/v1/remember"},
            }
        },
    }


def _make_event_alert_payload(
    issue_id: str = "456",
    error_class: str = "TimeoutError",
    error_value: str = "request timed out",
    culprit: str = "cortex.provider.openai in complete",
) -> dict:
    return {
        "action": "triggered",
        "data": {
            "event": {
                "event_id": "abc123",
                "datetime": "2026-03-18T06:00:00.000Z",
                "culprit": culprit,
                "issue_id": issue_id,
                "issue_url": f"https://sentry.io/api/0/organizations/es/issues/{issue_id}/",
                "web_url": f"https://electric-sheep.sentry.io/issues/{issue_id}/",
                "level": "error",
                "title": f"{error_class}: {error_value}",
                "exception": {
                    "values": [
                        {
                            "type": error_class,
                            "value": error_value,
                            "stacktrace": {"frames": []},
                        }
                    ]
                },
                "tags": [["environment", "production"]],
                "request": {},
            },
            "triggered_rule": "New Issue Alert",
        },
    }


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest_asyncio.fixture
async def http_app():
    """FastAPI test app with fleet routes registered, no secret (dev mode)."""
    from fastapi import FastAPI
    from httpx import AsyncClient, ASGITransport
    from cortex.api.routes import register_routes
    from cortex.api.deps import get_plugin_holder

    app = FastAPI()

    async def _noop_auth():
        pass

    with patch.dict(os.environ, {"AEGIS_SENTRY_SECRET": "", "AEGIS_FLEET_URL": "http://localhost:19999"}):
        register_routes(app, _noop_auth)

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client

    get_plugin_holder().pop("plugin", None)


@pytest_asyncio.fixture
async def http_app_with_secret():
    """FastAPI test app with HMAC secret enabled."""
    from fastapi import FastAPI
    from httpx import AsyncClient, ASGITransport
    from cortex.api.routes import register_routes
    from cortex.api.deps import get_plugin_holder

    app = FastAPI()

    async def _noop_auth():
        pass

    with patch.dict(os.environ, {"AEGIS_SENTRY_SECRET": "test-secret-123", "AEGIS_FLEET_URL": "http://localhost:19999"}):
        register_routes(app, _noop_auth)

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client

    get_plugin_holder().pop("plugin", None)


# ---------------------------------------------------------------------------
# Tests: HMAC verification
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_hmac_valid_signature_passes(http_app_with_secret):
    """Valid HMAC signature → 200."""
    payload = _make_issue_payload()
    body = json.dumps(payload).encode()
    sig = _sign(body, "test-secret-123")

    with patch("cortex.api.routes.fleet._fleet_announce", new_callable=AsyncMock):
        with patch("cortex.api.routes.fleet._is_duplicate", new_callable=AsyncMock, return_value=False):
            with patch("cortex.api.routes.fleet._get_last_deploy_time", new_callable=AsyncMock, return_value=None):
                with patch("cortex.api.routes.fleet._record_event", new_callable=AsyncMock):
                    resp = await http_app_with_secret.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={
                            "Content-Type": "application/json",
                            "Sentry-Hook-Signature": sig,
                            "Sentry-Hook-Resource": "issue",
                        },
                    )
    assert resp.status_code == 200, resp.text


@pytest.mark.asyncio
async def test_hmac_invalid_signature_returns_401(http_app_with_secret):
    """Wrong HMAC signature → 401."""
    payload = _make_issue_payload()
    body = json.dumps(payload).encode()
    wrong_sig = _sign(body, "wrong-secret")

    resp = await http_app_with_secret.post(
        "/api/v1/fleet/sentry",
        content=body,
        headers={
            "Content-Type": "application/json",
            "Sentry-Hook-Signature": wrong_sig,
        },
    )
    assert resp.status_code == 401


@pytest.mark.asyncio
async def test_hmac_missing_header_returns_401(http_app_with_secret):
    """Missing Sentry-Hook-Signature header → 401 when secret is set."""
    payload = _make_issue_payload()
    body = json.dumps(payload).encode()

    resp = await http_app_with_secret.post(
        "/api/v1/fleet/sentry",
        content=body,
        headers={"Content-Type": "application/json"},
    )
    assert resp.status_code == 401


@pytest.mark.asyncio
async def test_hmac_skipped_when_no_secret(http_app):
    """No AEGIS_SENTRY_SECRET set → signature check skipped, request succeeds."""
    payload = _make_issue_payload()
    body = json.dumps(payload).encode()

    with patch("cortex.api.routes.fleet._fleet_announce", new_callable=AsyncMock):
        with patch("cortex.api.routes.fleet._is_duplicate", new_callable=AsyncMock, return_value=False):
            with patch("cortex.api.routes.fleet._get_last_deploy_time", new_callable=AsyncMock, return_value=None):
                with patch("cortex.api.routes.fleet._record_event", new_callable=AsyncMock):
                    resp = await http_app.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={"Content-Type": "application/json"},
                    )
    assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Tests: Categorizer
# ---------------------------------------------------------------------------

def _make_event(
    error_class: str = "KeyError",
    error_message: str = "",
    culprit: str = "",
    substatus: str = "new",
    level: str = "error",
    count: int = 1,
    first_seen_offset_min: int = 0,
) -> "SentryEvent":
    from cortex.aegis.categorizer import SentryEvent

    now = datetime.now(timezone.utc)
    first_seen = now - timedelta(minutes=first_seen_offset_min)
    return SentryEvent(
        error_class=error_class,
        error_message=error_message,
        culprit=culprit,
        endpoint="/api/v1/remember",
        sentry_level=level,
        substatus=substatus,
        first_seen=first_seen,
        last_seen=now,
        count=count,
        issue_id="test-123",
        short_id="CORTEX-1",
        sentry_url="https://sentry.io/issues/123/",
        release="v1.5.2",
        environment="production",
        stack_frames=[],
    )


def test_categorizer_config_error():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="KeyError",
        error_message="OPENAI_API_KEY",
        culprit="cortex.config.loader in load",
    )
    alert = categorize(event)
    assert alert.category == "config_error"


def test_categorizer_provider_failure():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="APIError",
        error_message="503 service unavailable",
        culprit="cortex.provider.openai in complete",
    )
    alert = categorize(event)
    assert alert.category == "provider_failure"


def test_categorizer_rate_limit():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="RateLimitError",
        error_message="429 Too Many Requests",
        culprit="cortex.provider.anthropic in chat",
    )
    alert = categorize(event)
    assert alert.category == "rate_limit"


def test_categorizer_timeout():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="TimeoutError",
        error_message="request timed out after 30s",
        culprit="cortex.provider.openai in embed",
    )
    alert = categorize(event)
    assert alert.category == "timeout"


def test_categorizer_deploy_regression_within_window():
    from cortex.aegis.categorizer import categorize

    now = datetime.now(timezone.utc)
    last_deploy = now - timedelta(minutes=20)  # deployed 20min ago

    event = _make_event(
        error_class="AttributeError",
        error_message="object has no attribute 'foo'",
        culprit="cortex.core.storage in store",
        substatus="new",
        first_seen_offset_min=15,  # first_seen 15min ago → within 30min of deploy
    )
    # Adjust first_seen to be after deploy
    event.first_seen = last_deploy + timedelta(minutes=5)

    alert = categorize(event, last_deploy_time=last_deploy)
    assert alert.category == "deploy_regression"
    assert alert.deploy_correlated is True


def test_categorizer_no_deploy_regression_outside_window():
    from cortex.aegis.categorizer import categorize

    now = datetime.now(timezone.utc)
    last_deploy = now - timedelta(hours=2)  # deployed 2hrs ago

    event = _make_event(
        error_class="AttributeError",
        error_message="object has no attribute 'foo'",
        culprit="cortex.core.storage in store",
        substatus="new",
        first_seen_offset_min=5,
    )

    alert = categorize(event, last_deploy_time=last_deploy)
    assert alert.category != "deploy_regression"
    assert alert.deploy_correlated is False


def test_categorizer_severity_critical_for_new():
    from cortex.aegis.categorizer import categorize

    event = _make_event(substatus="new", level="error", count=1)
    alert = categorize(event)
    assert alert.severity == "critical"


def test_categorizer_severity_warning_for_regressed():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="HTTPError",
        substatus="regressed",
        level="error",
        count=10,
    )
    alert = categorize(event)
    assert alert.severity == "warning"


def test_categorizer_severity_info_for_single_occurrence():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="HTTPError",
        substatus="ongoing",
        level="warning",
        count=1,
    )
    alert = categorize(event)
    assert alert.severity == "info"


def test_categorizer_storage_error():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="OperationalError",
        error_message="disk full",
        culprit="cortex.storage.sqlite_adapter in write",
    )
    alert = categorize(event)
    assert alert.category == "storage_error"


def test_categorizer_fleet_error():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="FleetError",
        error_message="announce failed",
        culprit="cortex.fleet.announce in dispatch",
    )
    alert = categorize(event)
    assert alert.category == "fleet_error"


def test_categorizer_unknown_fallback():
    from cortex.aegis.categorizer import categorize

    event = _make_event(
        error_class="SomeObscureError",
        error_message="something went wrong",
        culprit="third_party.lib in do_thing",
    )
    alert = categorize(event)
    assert alert.category == "unknown"


# ---------------------------------------------------------------------------
# Tests: Dedup logic
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_dedup_same_issue_id_suppressed(http_app):
    """Same issue_id twice within 4h → second call returns 'deduped'."""
    payload = _make_issue_payload(issue_id="dedup-test-1")
    body = json.dumps(payload).encode()

    announce_mock = AsyncMock()
    deploy_mock = AsyncMock(return_value=None)
    record_mock = AsyncMock()

    call_count = 0

    async def _fake_is_duplicate(issue_id, severity):
        nonlocal call_count
        call_count += 1
        # First call: not a dup; second call: is a dup
        return call_count > 1

    with patch("cortex.api.routes.fleet._fleet_announce", announce_mock):
        with patch("cortex.api.routes.fleet._is_duplicate", side_effect=_fake_is_duplicate):
            with patch("cortex.api.routes.fleet._get_last_deploy_time", deploy_mock):
                with patch("cortex.api.routes.fleet._record_event", record_mock):
                    resp1 = await http_app.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={"Content-Type": "application/json"},
                    )
                    resp2 = await http_app.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={"Content-Type": "application/json"},
                    )

    assert resp1.status_code == 200
    data1 = resp1.json()
    assert data1.get("action") != "deduped"

    assert resp2.status_code == 200
    data2 = resp2.json()
    assert data2.get("action") == "deduped"

    # Announce should only fire once (first call)
    assert announce_mock.call_count == 1


@pytest.mark.asyncio
async def test_dedup_db_based_suppression():
    """Test the _is_duplicate function with an actual temp aegis.db."""
    import tempfile
    import os
    from cortex.api.routes import fleet

    with tempfile.TemporaryDirectory() as tmpdir:
        db_path = os.path.join(tmpdir, "aegis.db")
        with patch.dict(os.environ, {"CORTEX_DATA_DIR": tmpdir}):
            # First check — should not be a dup
            result1 = await fleet._is_duplicate("issue-abc", "critical")
            assert result1 is False

            # Create a fresh alert_state entry as if we just announced
            import sqlite3
            conn = sqlite3.connect(db_path)
            fleet._init_aegis_db(conn)
            now_iso = datetime.now(timezone.utc).isoformat()
            conn.execute(
                "INSERT INTO alert_state (issue_id, last_announced, last_severity, announce_count) VALUES (?, ?, ?, 1)",
                ("issue-abc", now_iso, "critical"),
            )
            conn.commit()
            conn.close()

            # Second check — same severity, within window → should be dup
            result2 = await fleet._is_duplicate("issue-abc", "critical")
            assert result2 is True

            # Lower severity → still dup (no escalation)
            result3 = await fleet._is_duplicate("issue-abc", "info")
            assert result3 is True


# ---------------------------------------------------------------------------
# Tests: Route integration
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_route_issue_payload_returns_category(http_app):
    """Issue webhook → 200 with category and severity."""
    payload = _make_issue_payload(
        error_class="RateLimitError",
        error_value="quota exceeded",
        culprit="cortex.provider.openai in embed",
    )
    body = json.dumps(payload).encode()

    with patch("cortex.api.routes.fleet._fleet_announce", new_callable=AsyncMock):
        with patch("cortex.api.routes.fleet._is_duplicate", new_callable=AsyncMock, return_value=False):
            with patch("cortex.api.routes.fleet._get_last_deploy_time", new_callable=AsyncMock, return_value=None):
                with patch("cortex.api.routes.fleet._record_event", new_callable=AsyncMock):
                    resp = await http_app.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={
                            "Content-Type": "application/json",
                            "Sentry-Hook-Resource": "issue",
                        },
                    )

    assert resp.status_code == 200
    data = resp.json()
    assert data["status"] == "ok"
    assert data["category"] == "rate_limit"
    assert "severity" in data


@pytest.mark.asyncio
async def test_route_event_alert_payload(http_app):
    """event_alert webhook → 200 with category."""
    payload = _make_event_alert_payload(
        error_class="TimeoutError",
        culprit="cortex.provider.openai in complete",
    )
    body = json.dumps(payload).encode()

    with patch("cortex.api.routes.fleet._fleet_announce", new_callable=AsyncMock):
        with patch("cortex.api.routes.fleet._is_duplicate", new_callable=AsyncMock, return_value=False):
            with patch("cortex.api.routes.fleet._get_last_deploy_time", new_callable=AsyncMock, return_value=None):
                with patch("cortex.api.routes.fleet._record_event", new_callable=AsyncMock):
                    resp = await http_app.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={
                            "Content-Type": "application/json",
                            "Sentry-Hook-Resource": "event_alert",
                        },
                    )

    assert resp.status_code == 200
    data = resp.json()
    assert data["status"] == "ok"
    assert data["category"] == "timeout"


@pytest.mark.asyncio
async def test_route_no_issue_or_event_data_skipped(http_app):
    """Webhook with no issue/event data → 200 with action=skipped."""
    payload = {"action": "resolved", "data": {}}
    body = json.dumps(payload).encode()

    resp = await http_app.post(
        "/api/v1/fleet/sentry",
        content=body,
        headers={"Content-Type": "application/json"},
    )
    assert resp.status_code == 200
    assert resp.json()["action"] == "skipped"


@pytest.mark.asyncio
async def test_route_fleet_announce_called_with_correct_payload(http_app):
    """Fleet announce is called with the right structure."""
    payload = _make_issue_payload(
        issue_id="announce-test",
        short_id="CORTEX-99",
        error_class="OperationalError",
        culprit="cortex.storage.sqlite_adapter in write",
    )
    body = json.dumps(payload).encode()

    announce_mock = AsyncMock()
    with patch("cortex.api.routes.fleet._fleet_announce", announce_mock):
        with patch("cortex.api.routes.fleet._is_duplicate", new_callable=AsyncMock, return_value=False):
            with patch("cortex.api.routes.fleet._get_last_deploy_time", new_callable=AsyncMock, return_value=None):
                with patch("cortex.api.routes.fleet._record_event", new_callable=AsyncMock):
                    resp = await http_app.post(
                        "/api/v1/fleet/sentry",
                        content=body,
                        headers={"Content-Type": "application/json"},
                    )

    assert resp.status_code == 200
    assert announce_mock.call_count == 1

    # Inspect announce call args
    args = announce_mock.call_args
    alert_arg = args[0][0]   # first positional arg = CategorizedAlert
    short_id_arg = args[0][1]  # second = short_id

    assert short_id_arg == "CORTEX-99"
    assert alert_arg.source == "aegis-sentry"
    assert alert_arg.category == "storage_error"
    assert alert_arg.severity in ("critical", "warning", "info")
    assert alert_arg.suggested_playbook == "check_db_health"


# ---------------------------------------------------------------------------
# Tests: Config
# ---------------------------------------------------------------------------

def test_config_aegis_sentry_secret_field():
    """CortexConfig has aegis_sentry_secret field with empty default."""
    from cortex.config.model import CortexConfig

    cfg = CortexConfig()
    assert hasattr(cfg, "aegis_sentry_secret")
    assert cfg.aegis_sentry_secret == ""


def test_config_aegis_sentry_secret_env_override():
    """CORTEX_AEGIS_SENTRY_SECRET env var overrides the default."""
    from cortex.config.model import CortexConfig
    from dataclasses import fields

    # Verify the field exists and is a str with empty default
    field_map = {f.name: f for f in fields(CortexConfig)}
    assert "aegis_sentry_secret" in field_map
    f = field_map["aegis_sentry_secret"]
    assert f.default == ""

    # Verify env-override works via CortexConfig constructor
    with patch.dict(os.environ, {"CORTEX_AEGIS_SENTRY_SECRET": "my-sentry-secret"}):
        overrides = CortexConfig._env_overrides()
        cfg = CortexConfig(**overrides)
    assert cfg.aegis_sentry_secret == "my-sentry-secret"


# ---------------------------------------------------------------------------
# Tests: fleet_categorizer re-export
# ---------------------------------------------------------------------------

def test_fleet_categorizer_module_exports():
    """cortex.api.routes.fleet_categorizer re-exports the public API."""
    from cortex.api.routes.fleet_categorizer import (
        SentryEvent,
        CategorizedAlert,
        categorize,
    )
    assert callable(categorize)
    assert SentryEvent is not None
    assert CategorizedAlert is not None
