"""
tests/test_webhooks.py — Webhook system test suite (M20 / #183).

Coverage:
  1.  Webhook dataclass construction
  2.  WebhookDispatcher.register() persists to DB
  3.  storage.create_webhook / get_webhook round-trip
  4.  storage.list_webhooks returns correct rows
  5.  storage.update_webhook patches url
  6.  storage.update_webhook patches events
  7.  storage.update_webhook patches active flag
  8.  storage.delete_webhook removes row
  9.  HMAC-SHA256 signature generation — correct hex digest
  10. HMAC signature verified with wrong secret fails
  11. dispatch() fires HTTP POST to matching webhook
  12. dispatch() skips webhooks with non-matching event types
  13. dispatch() skips inactive webhooks
  14. dispatch() retries on HTTP error (3 attempts)
  15. dispatch() delivery is logged to webhook_delivery table
  16. EventEmitter.emit() triggers WebhookDispatcher.dispatch()
  17. HTTP GET /api/v1/webhooks returns list
  18. HTTP POST /api/v1/webhooks creates webhook
  19. HTTP GET /api/v1/webhooks/{id} returns detail
  20. HTTP PATCH /api/v1/webhooks/{id} updates fields
  21. HTTP DELETE /api/v1/webhooks/{id} removes webhook
  22. HTTP DELETE /api/v1/webhooks/{id} returns 404 for missing id
  23. HTTP POST /api/v1/webhooks/{id}/test fires test delivery
"""
from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import os
import socket
import tempfile
from datetime import datetime, timezone
from typing import List
from unittest.mock import AsyncMock, MagicMock, patch, call

import pytest
import pytest_asyncio


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

def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


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

@pytest_asyncio.fixture
async def db_adapter():
    """Fresh SQLiteAdapter backed by a temp file."""
    from cortex.storage.sqlite_adapter import SQLiteAdapter

    with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
        db_path = f.name

    adapter = SQLiteAdapter(db_path=db_path)
    await adapter.initialize()
    yield adapter
    await adapter.close()
    try:
        os.unlink(db_path)
    except OSError:
        pass


@pytest_asyncio.fixture
async def dispatcher(db_adapter):
    """WebhookDispatcher wired to a live SQLiteAdapter."""
    from cortex.events.webhook import WebhookDispatcher
    return WebhookDispatcher(storage=db_adapter, http_timeout=2.0)


@pytest_asyncio.fixture
async def http_app(db_adapter):
    """FastAPI test client with plugin wired to the test DB."""
    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

    register_routes(app, _noop_auth)

    # Minimal plugin-like object
    plugin = MagicMock()
    plugin.storage = db_adapter
    get_plugin_holder()["plugin"] = plugin

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

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


# ---------------------------------------------------------------------------
# Test 1 — Webhook dataclass construction
# ---------------------------------------------------------------------------

def test_webhook_dataclass():
    from cortex.events.webhook import Webhook
    from cortex.events.emitter import EventType

    wh = Webhook(
        id="abc123",
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED],
        secret="s3cr3t",
        active=True,
        created_at=_now_iso(),
        name="My Hook",
    )

    assert wh.id == "abc123"
    assert wh.owner_id == "alice"
    assert wh.url == "https://example.com/hook"
    assert EventType.MEMORY_CREATED in wh.events
    assert wh.secret == "s3cr3t"
    assert wh.active is True
    assert wh.name == "My Hook"


# ---------------------------------------------------------------------------
# Test 2 — register() persists to DB
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_register_persists_to_db(dispatcher, db_adapter):
    from cortex.events.emitter import EventType

    wh = await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED, EventType.ENTITY_CREATED],
        secret="topsecret",
        name="Test Hook",
    )

    row = await db_adapter.get_webhook(wh.id)
    assert row is not None
    assert row["url"] == "https://example.com/hook"
    assert row["owner_id"] == "alice"
    assert "memory.created" in row["events"]
    assert row["active"] == 1


# ---------------------------------------------------------------------------
# Test 3 — create_webhook / get_webhook round-trip
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_create_get_webhook_round_trip(db_adapter):
    from cortex.events.emitter import EventType

    wh_id = "test-id-001"
    row = await db_adapter.create_webhook(
        webhook_id=wh_id,
        owner_id="bob",
        url="https://bob.example.com/cb",
        events=["memory.created"],
        secret=None,
        name="Bob Hook",
    )
    assert row["id"] == wh_id

    fetched = await db_adapter.get_webhook(wh_id)
    assert fetched is not None
    assert fetched["url"] == "https://bob.example.com/cb"
    assert fetched["name"] == "Bob Hook"
    assert fetched["active"] == 1


# ---------------------------------------------------------------------------
# Test 4 — list_webhooks returns correct rows
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_list_webhooks(db_adapter):
    for i in range(3):
        await db_adapter.create_webhook(
            webhook_id=f"wh-{i}",
            owner_id="alice",
            url=f"https://example.com/hook{i}",
            events=["session.started"],
            name=f"Hook {i}",
        )

    rows = await db_adapter.list_webhooks(owner_id="alice")
    assert len(rows) == 3
    urls = {r["url"] for r in rows}
    assert "https://example.com/hook0" in urls
    assert "https://example.com/hook2" in urls


# ---------------------------------------------------------------------------
# Test 5 — update_webhook patches url
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_update_webhook_url(db_adapter):
    await db_adapter.create_webhook(
        webhook_id="upd-001",
        owner_id="alice",
        url="https://old.example.com/hook",
        events=["memory.created"],
        name="Old",
    )
    updated = await db_adapter.update_webhook("upd-001", url="https://new.example.com/hook")
    assert updated["url"] == "https://new.example.com/hook"


# ---------------------------------------------------------------------------
# Test 6 — update_webhook patches events
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_update_webhook_events(db_adapter):
    await db_adapter.create_webhook(
        webhook_id="upd-002",
        owner_id="alice",
        url="https://example.com/hook",
        events=["memory.created"],
        name="Hook",
    )
    updated = await db_adapter.update_webhook(
        "upd-002",
        events=["memory.created", "memory.deleted"],
    )
    events_stored = json.loads(updated["events"])
    assert "memory.deleted" in events_stored


# ---------------------------------------------------------------------------
# Test 7 — update_webhook patches active flag
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_update_webhook_active(db_adapter):
    await db_adapter.create_webhook(
        webhook_id="upd-003",
        owner_id="alice",
        url="https://example.com/hook",
        events=["session.started"],
        name="Hook",
    )
    updated = await db_adapter.update_webhook("upd-003", active=0)
    assert updated["active"] == 0


# ---------------------------------------------------------------------------
# Test 8 — delete_webhook removes row
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_delete_webhook(db_adapter):
    await db_adapter.create_webhook(
        webhook_id="del-001",
        owner_id="alice",
        url="https://example.com/hook",
        events=["memory.created"],
        name="DeleteMe",
    )
    result = await db_adapter.delete_webhook("del-001")
    assert result is True

    gone = await db_adapter.get_webhook("del-001")
    assert gone is None


# ---------------------------------------------------------------------------
# Test 9 — HMAC-SHA256 signature — correct hex digest
# ---------------------------------------------------------------------------

def test_hmac_signature_correct():
    from cortex.events.webhook import _generate_signature

    payload = b'{"test": "data"}'
    secret = "my-secret"
    expected = hmac.new(b"my-secret", payload, hashlib.sha256).hexdigest()
    actual = _generate_signature(payload, secret)
    assert actual == expected


# ---------------------------------------------------------------------------
# Test 10 — HMAC with wrong secret fails
# ---------------------------------------------------------------------------

def test_hmac_wrong_secret():
    from cortex.events.webhook import _generate_signature

    payload = b'{"test": "data"}'
    sig_correct = _generate_signature(payload, "correct-secret")
    sig_wrong = _generate_signature(payload, "wrong-secret")
    assert sig_correct != sig_wrong


# ---------------------------------------------------------------------------
# Test 11 — dispatch() fires HTTP POST to matching webhook
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_dispatch_sends_http_post(db_adapter):
    from cortex.events.webhook import WebhookDispatcher
    from cortex.events.emitter import CortexEvent, EventType

    dispatcher = WebhookDispatcher(storage=db_adapter, http_timeout=2.0)
    wh = await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED],
    )

    event = CortexEvent(
        event_type=EventType.MEMORY_CREATED,
        owner_id="alice",
        payload={"claim_id": "c1"},
    )

    # Patch the http_post to avoid real network calls
    with patch.object(dispatcher, "_http_post", new=AsyncMock(return_value=200)) as mock_post:
        await dispatcher.dispatch(event)
        # Let fire-and-forget tasks complete
        await asyncio.sleep(0.05)

    mock_post.assert_called_once()
    call_url = mock_post.call_args[0][0]
    assert call_url == "https://example.com/hook"


# ---------------------------------------------------------------------------
# Test 12 — dispatch() skips webhooks with non-matching event types
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_dispatch_skips_non_matching_events(db_adapter):
    from cortex.events.webhook import WebhookDispatcher
    from cortex.events.emitter import CortexEvent, EventType

    dispatcher = WebhookDispatcher(storage=db_adapter, http_timeout=2.0)
    # Subscribe only to ENTITY_CREATED
    await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.ENTITY_CREATED],
    )

    # Emit MEMORY_CREATED — should NOT be delivered
    event = CortexEvent(
        event_type=EventType.MEMORY_CREATED,
        owner_id="alice",
        payload={},
    )

    with patch.object(dispatcher, "_http_post", new=AsyncMock(return_value=200)) as mock_post:
        await dispatcher.dispatch(event)
        await asyncio.sleep(0.05)

    mock_post.assert_not_called()


# ---------------------------------------------------------------------------
# Test 13 — dispatch() skips inactive webhooks
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_dispatch_skips_inactive_webhooks(db_adapter):
    from cortex.events.webhook import WebhookDispatcher
    from cortex.events.emitter import CortexEvent, EventType

    dispatcher = WebhookDispatcher(storage=db_adapter, http_timeout=2.0)
    wh = await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED],
    )
    # Deactivate the webhook
    await db_adapter.update_webhook(wh.id, active=0)

    event = CortexEvent(
        event_type=EventType.MEMORY_CREATED,
        owner_id="alice",
        payload={},
    )

    with patch.object(dispatcher, "_http_post", new=AsyncMock(return_value=200)) as mock_post:
        await dispatcher.dispatch(event)
        await asyncio.sleep(0.05)

    mock_post.assert_not_called()


# ---------------------------------------------------------------------------
# Test 14 — dispatch() retries on HTTP error (3 attempts)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_dispatch_retries_on_failure(db_adapter):
    from cortex.events.webhook import WebhookDispatcher
    from cortex.events.emitter import CortexEvent, EventType

    dispatcher = WebhookDispatcher(storage=db_adapter, http_timeout=2.0)
    # Patch RETRY_DELAYS to zero for speed
    dispatcher.RETRY_DELAYS = (0, 0, 0)
    wh = await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED],
    )

    event = CortexEvent(
        event_type=EventType.MEMORY_CREATED,
        owner_id="alice",
        payload={},
    )

    # Always return 500
    with patch.object(dispatcher, "_http_post", new=AsyncMock(return_value=500)) as mock_post:
        await dispatcher._deliver_with_retry(wh, event, "memory.created")

    assert mock_post.call_count == 3


# ---------------------------------------------------------------------------
# Test 15 — delivery is logged to webhook_delivery table
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_dispatch_logs_delivery(db_adapter):
    from cortex.events.webhook import WebhookDispatcher
    from cortex.events.emitter import CortexEvent, EventType
    import aiosqlite

    dispatcher = WebhookDispatcher(storage=db_adapter, http_timeout=2.0)
    dispatcher.RETRY_DELAYS = (0, 0, 0)
    wh = await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED],
    )

    event = CortexEvent(
        event_type=EventType.MEMORY_CREATED,
        owner_id="alice",
        payload={},
    )

    with patch.object(dispatcher, "_http_post", new=AsyncMock(return_value=200)):
        await dispatcher._deliver_with_retry(wh, event, "memory.created")

    # Check delivery row exists
    conn = await db_adapter._get_conn()
    async with conn.execute(
        "SELECT * FROM webhook_delivery WHERE webhook_id = ?", (wh.id,)
    ) as cur:
        rows = await cur.fetchall()

    assert len(rows) >= 1
    row = dict(rows[0])
    assert row["success"] == 1
    assert row["status_code"] == 200


# ---------------------------------------------------------------------------
# Test 16 — EventEmitter.emit() triggers WebhookDispatcher.dispatch()
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_emitter_triggers_webhook_dispatcher(db_adapter):
    from cortex.events.emitter import EventEmitter, CortexEvent, EventType
    from cortex.events.webhook import WebhookDispatcher

    dispatcher = WebhookDispatcher(storage=db_adapter, http_timeout=2.0)
    emitter = EventEmitter(storage=db_adapter, webhook_dispatcher=dispatcher)

    event = CortexEvent(
        event_type=EventType.SESSION_STARTED,
        owner_id="alice",
    )

    with patch.object(dispatcher, "dispatch", new=AsyncMock()) as mock_dispatch:
        await emitter.emit(event)

    mock_dispatch.assert_called_once_with(event)


# ---------------------------------------------------------------------------
# HTTP endpoint tests (17–23)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_http_list_webhooks_empty(http_app):
    resp = await http_app.get("/api/v1/webhooks?owner_id=nobody")
    assert resp.status_code == 200
    data = resp.json()
    assert data["count"] == 0
    assert data["webhooks"] == []


@pytest.mark.asyncio
async def test_http_create_webhook(http_app):
    resp = await http_app.post(
        "/api/v1/webhooks?owner_id=alice",
        json={"url": "https://example.com/hook", "events": ["memory.created"]},
    )
    assert resp.status_code == 201
    data = resp.json()
    assert data["url"] == "https://example.com/hook"
    assert "memory.created" in data["events"]
    assert data["owner_id"] == "alice"
    assert data["active"] is True


@pytest.mark.asyncio
async def test_http_get_webhook_detail(http_app):
    # Create first
    create_resp = await http_app.post(
        "/api/v1/webhooks?owner_id=alice",
        json={"url": "https://example.com/detail-hook", "events": ["entity.created"]},
    )
    wh_id = create_resp.json()["id"]

    # Fetch detail
    resp = await http_app.get(f"/api/v1/webhooks/{wh_id}")
    assert resp.status_code == 200
    data = resp.json()
    assert data["id"] == wh_id
    assert "entity.created" in data["events"]


@pytest.mark.asyncio
async def test_http_patch_webhook(http_app):
    _public_ip = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
    with patch("cortex.events.webhook.socket.getaddrinfo", return_value=_public_ip):
        create_resp = await http_app.post(
            "/api/v1/webhooks?owner_id=alice",
            json={"url": "https://old.example.com/hook", "events": ["memory.created"]},
        )
        wh_id = create_resp.json()["id"]

        resp = await http_app.patch(
            f"/api/v1/webhooks/{wh_id}",
            json={"url": "https://new.example.com/hook", "active": False},
        )
    assert resp.status_code == 200
    data = resp.json()
    assert data["url"] == "https://new.example.com/hook"
    assert data["active"] is False


@pytest.mark.asyncio
async def test_http_delete_webhook(http_app):
    create_resp = await http_app.post(
        "/api/v1/webhooks?owner_id=alice",
        json={"url": "https://example.com/delete-hook", "events": []},
    )
    wh_id = create_resp.json()["id"]

    del_resp = await http_app.delete(f"/api/v1/webhooks/{wh_id}")
    assert del_resp.status_code == 204

    get_resp = await http_app.get(f"/api/v1/webhooks/{wh_id}")
    assert get_resp.status_code == 404


@pytest.mark.asyncio
async def test_http_delete_webhook_not_found(http_app):
    resp = await http_app.delete("/api/v1/webhooks/nonexistent-id-xyz")
    assert resp.status_code == 404


@pytest.mark.asyncio
async def test_http_test_endpoint(http_app):
    create_resp = await http_app.post(
        "/api/v1/webhooks?owner_id=alice",
        json={"url": "https://example.com/test-hook", "events": ["session.started"]},
    )
    wh_id = create_resp.json()["id"]

    # Patch the dispatcher's _http_post at the module level so the route uses it
    from cortex.events import webhook as wh_module

    with patch.object(
        wh_module.WebhookDispatcher,
        "_http_post",
        new=AsyncMock(return_value=200),
    ):
        resp = await http_app.post(f"/api/v1/webhooks/{wh_id}/test")

    assert resp.status_code == 200
    data = resp.json()
    assert data["success"] is True
    assert data["status_code"] == 200
    assert data["webhook_id"] == wh_id


# ---------------------------------------------------------------------------
# SSRF URL validation tests (#435)
# ---------------------------------------------------------------------------

class TestWebhookURLValidation:
    def test_public_https_accepted(self):
        from cortex.events.webhook import validate_webhook_url
        with patch("cortex.events.webhook.socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]):
            validate_webhook_url("https://example.com/hook")  # should not raise

    def test_localhost_rejected(self):
        from cortex.events.webhook import validate_webhook_url
        with patch("cortex.events.webhook.socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]):
            with pytest.raises(ValueError, match="private/internal"):
                validate_webhook_url("http://localhost/hook")

    def test_aws_imds_rejected(self):
        from cortex.events.webhook import validate_webhook_url
        with patch("cortex.events.webhook.socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]):
            with pytest.raises(ValueError, match="private/internal"):
                validate_webhook_url("http://169.254.169.254/latest/meta-data/")

    def test_rfc1918_rejected(self):
        from cortex.events.webhook import validate_webhook_url
        with patch("cortex.events.webhook.socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", 0))]):
            with pytest.raises(ValueError, match="private/internal"):
                validate_webhook_url("http://10.0.0.1/admin")

    def test_allow_private_flag(self):
        from cortex.events.webhook import validate_webhook_url
        validate_webhook_url("http://localhost/hook", allow_private=True)

    def test_ftp_scheme_rejected(self):
        from cortex.events.webhook import validate_webhook_url
        with pytest.raises(ValueError, match="http or https"):
            validate_webhook_url("ftp://example.com/hook")

    def test_file_scheme_rejected(self):
        from cortex.events.webhook import validate_webhook_url
        with pytest.raises(ValueError, match="http or https"):
            validate_webhook_url("file:///etc/passwd")
