"""Tests for certisigma_census.webhook — secret management, ReplayGuard, receiver."""

from __future__ import annotations

import hashlib
import hmac
import json
import os
import socket
import threading
import time
from datetime import datetime, timezone, timedelta
from http.client import HTTPConnection
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from certisigma_census.webhook import (
    DEFAULT_PORT,
    MAX_PAYLOAD_BYTES,
    ReplayGuard,
    WebhookReceiver,
    load_secret,
    save_secret,
)


# ---------------------------------------------------------------------------
# Secret management
# ---------------------------------------------------------------------------


class TestSaveSecret:
    def test_creates_file_with_0o600(self, tmp_path: Path) -> None:
        p = tmp_path / "secret.txt"
        save_secret(p, "deadbeef" * 8, "wh_test1")
        assert p.exists()
        mode = p.stat().st_mode & 0o777
        assert mode == 0o600

    def test_file_contains_secret_and_header(self, tmp_path: Path) -> None:
        p = tmp_path / "secret.txt"
        secret = "a1b2c3d4" * 8
        save_secret(p, secret, "wh_42")
        text = p.read_text()
        assert secret in text
        assert "wh_42" in text
        assert "DO NOT share" in text

    def test_overwrites_existing(self, tmp_path: Path) -> None:
        p = tmp_path / "secret.txt"
        save_secret(p, "old_secret", "wh_1")
        save_secret(p, "new_secret", "wh_2")
        text = p.read_text()
        assert "new_secret" in text
        assert "old_secret" not in text


class TestLoadSecret:
    def test_reads_secret_from_file(self, tmp_path: Path) -> None:
        p = tmp_path / "secret.txt"
        save_secret(p, "hex64hex64hex64", "wh_x")
        loaded = load_secret(p)
        assert loaded == "hex64hex64hex64"

    def test_strips_comments_and_whitespace(self, tmp_path: Path) -> None:
        p = tmp_path / "s.txt"
        p.write_text("# comment\n# another\n  the_secret_value  \n\n")
        assert load_secret(p) == "the_secret_value"

    def test_empty_file_raises(self, tmp_path: Path) -> None:
        p = tmp_path / "empty.txt"
        p.write_text("# only comments\n")
        with pytest.raises(ValueError, match="No signing secret"):
            load_secret(p)

    def test_oversized_file_raises(self, tmp_path: Path) -> None:
        p = tmp_path / "big.txt"
        p.write_text("x" * 10_000)
        with pytest.raises(ValueError, match="bytes"):
            load_secret(p)


# ---------------------------------------------------------------------------
# ReplayGuard
# ---------------------------------------------------------------------------

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


def _past_iso(seconds: int) -> str:
    return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()


def _future_iso(seconds: int) -> str:
    return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()


class TestReplayGuard:
    def test_fresh_delivery_accepted(self) -> None:
        rg = ReplayGuard()
        assert rg.check_and_record(1, _now_iso()) is True

    def test_duplicate_rejected(self) -> None:
        rg = ReplayGuard()
        rg.check_and_record(1, _now_iso())
        assert rg.check_and_record(1, _now_iso()) is False

    def test_stale_delivery_rejected(self) -> None:
        rg = ReplayGuard(max_age_seconds=60)
        assert rg.check_and_record(99, _past_iso(120)) is False

    def test_future_within_tolerance(self) -> None:
        rg = ReplayGuard()
        assert rg.check_and_record(10, _future_iso(30)) is True

    def test_far_future_rejected(self) -> None:
        rg = ReplayGuard()
        assert rg.check_and_record(10, _future_iso(120)) is False

    def test_capacity_eviction(self) -> None:
        rg = ReplayGuard(max_ids=3)
        now = _now_iso()
        for i in range(5):
            assert rg.check_and_record(i, now) is True
        assert rg.size() == 3
        # Oldest (0, 1) should have been evicted → re-accepted
        assert rg.check_and_record(0, now) is True

    def test_invalid_timestamp_rejected(self) -> None:
        rg = ReplayGuard()
        assert rg.check_and_record(50, "not-a-date") is False

    def test_reset_clears_state(self) -> None:
        rg = ReplayGuard()
        rg.check_and_record(1, _now_iso())
        rg.reset()
        assert rg.size() == 0
        assert rg.check_and_record(1, _now_iso()) is True

    def test_string_key_accepted(self) -> None:
        rg = ReplayGuard()
        assert rg.check_and_record("uuid-abc", _now_iso()) is True
        assert rg.check_and_record("uuid-abc", _now_iso()) is False

    def test_thread_safety(self) -> None:
        rg = ReplayGuard(max_ids=50_000)
        now = _now_iso()
        results: list[bool] = []

        def _worker(start: int) -> None:
            for i in range(start, start + 1000):
                results.append(rg.check_and_record(i, now))

        threads = [threading.Thread(target=_worker, args=(i * 1000,)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert sum(results) == 5000  # all unique IDs accepted


# ---------------------------------------------------------------------------
# WebhookReceiver
# ---------------------------------------------------------------------------

def _find_free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def _sign(payload: bytes, secret: str) -> str:
    return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()


class TestWebhookReceiver:
    @pytest.fixture()
    def secret(self) -> str:
        return "test_secret_" + "ab" * 28

    @pytest.fixture()
    def receiver(self, secret: str):
        port = _find_free_port()
        t1_calls: list[dict] = []
        t2_calls: list[dict] = []

        srv = WebhookReceiver(
            secret,
            bind="127.0.0.1",
            port=port,
            on_t1=lambda p: t1_calls.append(p),
            on_t2=lambda p: t2_calls.append(p),
            replay_max_age=300.0,
        )
        srv.serve_in_thread()
        srv._test_t1_calls = t1_calls  # type: ignore[attr-defined]
        srv._test_t2_calls = t2_calls  # type: ignore[attr-defined]
        srv._test_port = port  # type: ignore[attr-defined]
        yield srv
        srv.shutdown()

    def _post(self, port: int, path: str, body: bytes, headers: dict) -> tuple[int, bytes]:
        conn = HTTPConnection("127.0.0.1", port, timeout=5)
        conn.request("POST", path, body=body, headers=headers)
        resp = conn.getresponse()
        data = resp.read()
        conn.close()
        return resp.status, data

    def _get(self, port: int, path: str) -> tuple[int, bytes]:
        conn = HTTPConnection("127.0.0.1", port, timeout=5)
        conn.request("GET", path)
        resp = conn.getresponse()
        data = resp.read()
        conn.close()
        return resp.status, data

    def test_health_endpoint(self, receiver: WebhookReceiver) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        status, body = self._get(port, "/health")
        assert status == 200
        assert json.loads(body)["status"] == "ok"

    def test_404_on_unknown_path(self, receiver: WebhookReceiver) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        status, _ = self._get(port, "/unknown")
        assert status == 404

    def test_valid_t1_delivery(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        payload = {
            "event_type": "t1_complete",
            "hash_hex": "abc123",
            "delivery_id": 1,
            "created_at": _now_iso(),
        }
        body = json.dumps(payload).encode()
        sig = _sign(body, secret)
        status, resp = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": sig,
            "Content-Length": str(len(body)),
        })
        assert status == 200
        assert json.loads(resp)["status"] == "ok"
        assert len(receiver._test_t1_calls) == 1  # type: ignore[attr-defined]
        assert receiver._test_t1_calls[0]["hash_hex"] == "abc123"  # type: ignore[attr-defined]

    def test_valid_t2_delivery(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        payload = {
            "event_type": "t2_complete",
            "hash_hex": "def456",
            "delivery_id": 2,
            "created_at": _now_iso(),
        }
        body = json.dumps(payload).encode()
        sig = _sign(body, secret)
        status, _ = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": sig,
            "Content-Length": str(len(body)),
        })
        assert status == 200
        assert len(receiver._test_t2_calls) == 1  # type: ignore[attr-defined]

    def test_invalid_signature_rejected(self, receiver: WebhookReceiver) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        body = json.dumps({"event_type": "t1_complete", "delivery_id": 3, "created_at": _now_iso()}).encode()
        status, _ = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": "sha256=bad_signature",
            "Content-Length": str(len(body)),
        })
        assert status == 401

    def test_replay_rejected(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        payload = {"event_type": "t1_complete", "delivery_id": 100, "created_at": _now_iso()}
        body = json.dumps(payload).encode()
        sig = _sign(body, secret)
        headers = {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": sig,
            "Content-Length": str(len(body)),
        }
        self._post(port, "/webhook/callback", body, headers)
        status, resp = self._post(port, "/webhook/callback", body, headers)
        assert status == 200
        assert json.loads(resp)["status"] == "duplicate"
        assert len(receiver._test_t1_calls) == 1  # type: ignore[attr-defined]

    def test_wrong_content_type_rejected(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        body = b'{"event_type":"t1_complete"}'
        status, _ = self._post(port, "/webhook/callback", body, {
            "Content-Type": "text/plain",
            "X-CertiSigma-Signature": _sign(body, secret),
            "Content-Length": str(len(body)),
        })
        assert status == 415

    def test_post_to_wrong_path_rejected(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        body = b'{"event_type":"t1_complete"}'
        status, _ = self._post(port, "/other", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": _sign(body, secret),
            "Content-Length": str(len(body)),
        })
        assert status == 404

    def test_empty_body_rejected(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        status, _ = self._post(port, "/webhook/callback", b"", {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": _sign(b"", secret),
            "Content-Length": "0",
        })
        assert status == 400

    def test_negative_content_length_rejected(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        body = b'{"event":"t1_complete"}'
        status, _ = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": _sign(body, secret),
            "Content-Length": "-1",
        })
        assert status == 400

    def test_oversized_signature_rejected(self, receiver: WebhookReceiver, secret: str) -> None:
        port = receiver._test_port  # type: ignore[attr-defined]
        body = b'{"event":"t1_complete"}'
        status, _ = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": "sha256=" + "a" * 200,
            "Content-Length": str(len(body)),
        })
        assert status == 400

    def test_delivery_id_zero_is_accepted(self, receiver: WebhookReceiver, secret: str) -> None:
        """delivery_id=0 is a valid int, must not be treated as missing."""
        port = receiver._test_port  # type: ignore[attr-defined]
        payload = {"event_type": "t1_complete", "delivery_id": 0, "created_at": _now_iso()}
        body = json.dumps(payload).encode()
        sig = _sign(body, secret)
        status, resp = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": sig,
            "Content-Length": str(len(body)),
        })
        assert status == 200
        assert json.loads(resp)["status"] == "ok"
        assert len(receiver._test_t1_calls) == 1  # type: ignore[attr-defined]

    def test_missing_delivery_id_still_dispatches(self, receiver: WebhookReceiver, secret: str) -> None:
        """Payload without delivery_id bypasses replay guard but still dispatches."""
        port = receiver._test_port  # type: ignore[attr-defined]
        payload = {"event_type": "t1_complete", "created_at": _now_iso()}
        body = json.dumps(payload).encode()
        sig = _sign(body, secret)
        status, resp = self._post(port, "/webhook/callback", body, {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": sig,
            "Content-Length": str(len(body)),
        })
        assert status == 200
        assert json.loads(resp)["status"] == "ok"
        assert len(receiver._test_t1_calls) == 1  # type: ignore[attr-defined]

    def test_string_delivery_id_dedup(self, receiver: WebhookReceiver, secret: str) -> None:
        """Non-integer delivery IDs stored as strings for correct dedup."""
        port = receiver._test_port  # type: ignore[attr-defined]
        payload = {"event_type": "t1_complete", "delivery_id": "uuid-abc-123", "created_at": _now_iso()}
        body = json.dumps(payload).encode()
        sig = _sign(body, secret)
        headers = {
            "Content-Type": "application/json",
            "X-CertiSigma-Signature": sig,
            "Content-Length": str(len(body)),
        }
        s1, _ = self._post(port, "/webhook/callback", body, headers)
        s2, r2 = self._post(port, "/webhook/callback", body, headers)
        assert s1 == 200
        assert s2 == 200
        assert json.loads(r2)["status"] == "duplicate"
        assert len(receiver._test_t1_calls) == 1  # type: ignore[attr-defined]


# ---------------------------------------------------------------------------
# CLI smoke tests (Click runner)
# ---------------------------------------------------------------------------

from click.testing import CliRunner
from certisigma_census.cli import main as cli_main


class TestWebhookCLI:
    """Basic invocation tests — SDK calls are mocked."""

    def test_webhook_help(self) -> None:
        runner = CliRunner()
        result = runner.invoke(cli_main, ["webhook", "--help"])
        assert result.exit_code == 0
        assert "register" in result.output
        assert "serve" in result.output
        assert "verify-payload" in result.output

    def test_webhook_register_help(self) -> None:
        runner = CliRunner()
        result = runner.invoke(cli_main, ["webhook", "register", "--help"])
        assert result.exit_code == 0
        assert "--url" in result.output
        assert "--events" in result.output
        assert "--save-secret" in result.output

    def test_webhook_serve_help(self) -> None:
        runner = CliRunner()
        result = runner.invoke(cli_main, ["webhook", "serve", "--help"])
        assert result.exit_code == 0
        assert "--secret-file" in result.output
        assert "--on-t1" in result.output
        assert "--on-t2" in result.output
        assert "--replay-window" in result.output

    def test_webhook_verify_payload_valid(self, tmp_path: Path) -> None:
        secret = "test_secret_abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
        payload = b'{"event":"t1_complete","hash":"abc"}'
        sig = _sign(payload, secret)

        payload_file = tmp_path / "payload.json"
        payload_file.write_bytes(payload)
        secret_file = tmp_path / "secret.txt"
        secret_file.write_text(secret)

        runner = CliRunner()
        result = runner.invoke(cli_main, [
            "webhook", "verify-payload", str(payload_file),
            "--signature", sig,
            "--secret-file", str(secret_file),
        ])
        assert result.exit_code == 0
        assert "valid" in result.output.lower()

    def test_webhook_verify_payload_invalid(self, tmp_path: Path) -> None:
        secret = "test_secret_abcdef1234567890"
        payload_file = tmp_path / "payload.json"
        payload_file.write_bytes(b'{"data":"test"}')
        secret_file = tmp_path / "secret.txt"
        secret_file.write_text(secret)

        runner = CliRunner()
        result = runner.invoke(cli_main, [
            "webhook", "verify-payload", str(payload_file),
            "--signature", "sha256=badbad",
            "--secret-file", str(secret_file),
        ])
        assert result.exit_code == 1
        assert "INVALID" in result.output

    @patch("certisigma_census.cli._get_client")
    def test_webhook_register_json(self, mock_get_client: MagicMock, tmp_path: Path) -> None:
        mock_client = MagicMock()
        mock_result = MagicMock()
        mock_result.id = "wh_test"
        mock_result.url = "https://example.com/hook"
        mock_result.events = ["t1_complete"]
        mock_result.signing_secret = "secret_hex_64"
        mock_result.active = True
        mock_result.created_at = "2026-03-18T00:00:00Z"
        mock_result.label = "test"
        mock_client.register_webhook.return_value = mock_result
        mock_get_client.return_value = mock_client

        runner = CliRunner()
        result = runner.invoke(cli_main, [
            "webhook", "register",
            "--url", "https://example.com/hook",
            "--events", "t1_complete",
            "--label", "test",
            "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["id"] == "wh_test"
        assert data["signing_secret"] == "secret_hex_64"
        assert "census_version" in data

    @patch("certisigma_census.cli._get_client")
    def test_webhook_register_save_secret(self, mock_get_client: MagicMock, tmp_path: Path) -> None:
        mock_client = MagicMock()
        mock_result = MagicMock()
        mock_result.id = "wh_save"
        mock_result.url = "https://example.com/hook"
        mock_result.events = ["t1_complete"]
        mock_result.signing_secret = "hex_secret_64chars"
        mock_result.active = True
        mock_result.created_at = "2026-03-18T00:00:00Z"
        mock_result.label = None
        mock_client.register_webhook.return_value = mock_result
        mock_get_client.return_value = mock_client

        secret_path = tmp_path / "saved.txt"
        runner = CliRunner()
        result = runner.invoke(cli_main, [
            "webhook", "register",
            "--url", "https://example.com/hook",
            "--events", "t1_complete",
            "--save-secret", str(secret_path),
        ])
        assert result.exit_code == 0
        assert secret_path.exists()
        assert (secret_path.stat().st_mode & 0o777) == 0o600
        loaded = load_secret(secret_path)
        assert loaded == "hex_secret_64chars"

    @patch("certisigma_census.cli._get_client")
    def test_webhook_list_json(self, mock_get_client: MagicMock) -> None:
        mock_client = MagicMock()
        mock_wh = MagicMock()
        mock_wh.id = "wh_1"
        mock_wh.url = "https://example.com"
        mock_wh.events = ["t1_complete", "t2_complete"]
        mock_wh.active = True
        mock_wh.label = "prod"
        mock_wh.created_at = "2026-03-18T00:00:00Z"
        mock_wh.last_triggered_at = None
        mock_wh.failure_count = 0
        mock_result = MagicMock()
        mock_result.count = 1
        mock_result.webhooks = [mock_wh]
        mock_client.list_webhooks.return_value = mock_result
        mock_get_client.return_value = mock_client

        runner = CliRunner()
        result = runner.invoke(cli_main, ["webhook", "list", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["count"] == 1
        assert data["webhooks"][0]["id"] == "wh_1"

    @patch("certisigma_census.cli._get_client")
    def test_webhook_delete_json(self, mock_get_client: MagicMock) -> None:
        mock_client = MagicMock()
        mock_result = MagicMock()
        mock_result.id = "wh_del"
        mock_result.deleted = True
        mock_client.delete_webhook.return_value = mock_result
        mock_get_client.return_value = mock_client

        runner = CliRunner()
        result = runner.invoke(cli_main, ["webhook", "delete", "wh_del", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["deleted"] is True

    @patch("certisigma_census.cli._get_client")
    def test_webhook_deliveries_json(self, mock_get_client: MagicMock) -> None:
        mock_client = MagicMock()
        mock_del = MagicMock()
        mock_del.id = 1
        mock_del.event_type = "t1_complete"
        mock_del.status = "delivered"
        mock_del.response_code = 200
        mock_del.attempts = 1
        mock_del.created_at = "2026-03-18T00:00:00Z"
        mock_del.delivered_at = "2026-03-18T00:00:01Z"
        mock_result = MagicMock()
        mock_result.webhook_id = "wh_1"
        mock_result.count = 1
        mock_result.deliveries = [mock_del]
        mock_client.list_webhook_deliveries.return_value = mock_result
        mock_get_client.return_value = mock_client

        runner = CliRunner()
        result = runner.invoke(cli_main, ["webhook", "deliveries", "wh_1", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["webhook_id"] == "wh_1"
        assert data["deliveries"][0]["event_type"] == "t1_complete"

    def test_track_level_option_in_help(self) -> None:
        runner = CliRunner()
        result = runner.invoke(cli_main, ["track", "--help"])
        assert result.exit_code == 0
        assert "--level" in result.output
        assert "T1" in result.output

    def test_watch_t1_t2_options_in_help(self) -> None:
        runner = CliRunner()
        result = runner.invoke(cli_main, ["watch", "--help"])
        assert result.exit_code == 0
        assert "--on-t1" in result.output
        assert "--on-t2" in result.output
        assert "--webhook-secret-file" in result.output
        assert "--webhook-port" in result.output

    def test_verify_payload_oversized_file_rejected(self, tmp_path: Path) -> None:
        from certisigma_census.webhook import MAX_PAYLOAD_BYTES

        big_file = tmp_path / "big.json"
        big_file.write_bytes(b"x" * (MAX_PAYLOAD_BYTES + 1))
        secret_file = tmp_path / "secret.txt"
        secret_file.write_text("test_secret")

        runner = CliRunner()
        result = runner.invoke(cli_main, [
            "webhook", "verify-payload", str(big_file),
            "--signature", "sha256=abc",
            "--secret-file", str(secret_file),
        ])
        assert result.exit_code == 1
        assert "bytes" in result.output.lower() or "max" in result.output.lower()

    def test_verify_payload_oversized_signature_rejected(self, tmp_path: Path) -> None:
        from certisigma_census.webhook import MAX_SIGNATURE_LEN

        payload_file = tmp_path / "payload.json"
        payload_file.write_bytes(b'{"test": true}')
        secret_file = tmp_path / "secret.txt"
        secret_file.write_text("test_secret")

        long_sig = "sha256=" + "a" * (MAX_SIGNATURE_LEN + 50)
        runner = CliRunner()
        result = runner.invoke(cli_main, [
            "webhook", "verify-payload", str(payload_file),
            "--signature", long_sig,
            "--secret-file", str(secret_file),
        ])
        assert result.exit_code == 1
        assert "too long" in result.output.lower()
