"""Security tests for the Patter Python SDK.

Covers SSRF protection, XSS sanitisation, E.164 validation,
TwiML injection prevention, and secret leakage.
"""

from __future__ import annotations

import re

import pytest

from getpatter.telephony.common import _sanitize_variable_value, _validate_e164
from getpatter.telephony.twilio import _xml_escape, _validate_twilio_sid
from getpatter.local_config import LocalConfig
from getpatter.models import Agent
from getpatter.server import validate_webhook_url
from getpatter.tools.tool_executor import _validate_webhook_url


# ── SEC-1: SSRF on user-supplied webhook URLs ─────────────────────────────
# Python SDK does not expose a standalone validateWebhookUrl helper like TS,
# but the Twilio SID validator prevents path-traversal SSRF against the
# Twilio REST API.  We test that validator here as the SSRF surface.


@pytest.mark.security
class TestSSRFProtection:
    """SEC-1 — Reject internal/private addresses, accept public ones."""

    def test_rejects_metadata_style_sid(self) -> None:
        """A CallSid crafted to look like a cloud metadata path is rejected."""
        assert _validate_twilio_sid("CA" + "0" * 31 + "g", "CA") is False
        assert _validate_twilio_sid("../latest/meta-data/iam/securit", "CA") is False
        assert _validate_twilio_sid("", "CA") is False

    def test_rejects_short_and_long_sids(self) -> None:
        assert _validate_twilio_sid("CA" + "a" * 10, "CA") is False
        assert _validate_twilio_sid("CA" + "a" * 40, "CA") is False

    def test_accepts_valid_twilio_sid(self) -> None:
        valid_sid = "CA" + "a" * 32
        assert _validate_twilio_sid(valid_sid, "CA") is True

    def test_rejects_wrong_prefix(self) -> None:
        sid = "XX" + "a" * 32
        assert _validate_twilio_sid(sid, "CA") is False


# ── SEC-1b: SSRF via non-canonical IP encodings ───────────────────────────
# Regression for the SSRF-guard bypass: the dotted-quad-only checks let
# decimal / octal / hex / short IPv4 spellings, and IPv4-mapped IPv6, slip
# past the private-range rejection even though getaddrinfo resolves them to
# the very internal host the guard meant to block (incl. 169.254.169.254).

# Each entry resolves to an internal address the guard must reject, across
# every spelling a resolver accepts.
_SSRF_ENCODED_INTERNAL = [
    ("decimal 127.0.0.1", "http://2130706433/x"),
    ("hex 127.0.0.1", "http://0x7f.0.0.1/x"),
    ("octal 127.0.0.1", "http://0177.0.0.1/x"),
    ("short 127.0.0.1", "http://127.1/x"),
    ("decimal 169.254.169.254 metadata", "http://2852039166/latest/meta-data/"),
    ("ipv4-mapped loopback", "http://[::ffff:127.0.0.1]/x"),
    ("ipv4-mapped metadata", "http://[::ffff:169.254.169.254]/latest/meta-data/"),
]


@pytest.mark.security
class TestSSRFAlternateEncodings:
    """SEC-1b — every encoding of an internal IP is rejected like its
    dotted-quad form; a mapped public address stays allowed."""

    @pytest.mark.parametrize(
        "label,url", _SSRF_ENCODED_INTERNAL, ids=[c[0] for c in _SSRF_ENCODED_INTERNAL]
    )
    def test_tool_executor_rejects_encoded_internal(self, label: str, url: str) -> None:
        with pytest.raises(ValueError, match="private|reserved"):
            _validate_webhook_url(url)

    @pytest.mark.parametrize(
        "label,url", _SSRF_ENCODED_INTERNAL, ids=[c[0] for c in _SSRF_ENCODED_INTERNAL]
    )
    def test_server_mirror_rejects_encoded_internal(self, label: str, url: str) -> None:
        assert validate_webhook_url(url) is False

    def test_mapped_public_address_allowed(self) -> None:
        """An IPv4-mapped *public* address must remain reachable."""
        _validate_webhook_url("http://[::ffff:8.8.8.8]/x")  # no raise
        assert validate_webhook_url("http://[::ffff:8.8.8.8]/x") is True

    def test_public_hostname_and_ip_still_allowed(self) -> None:
        _validate_webhook_url("https://example.com/webhook")  # no raise
        assert validate_webhook_url("https://example.com/webhook") is True
        assert validate_webhook_url("http://8.8.8.8/x") is True

    def test_decimal_loopback_blocked_for_consult_only_when_strict(self) -> None:
        """allow_loopback bypasses the IP check (consult escape hatch), so an
        encoded loopback is permitted there — but stays blocked by default."""
        _validate_webhook_url("http://2130706433/x", allow_loopback=True)  # no raise
        with pytest.raises(ValueError, match="private|reserved"):
            _validate_webhook_url("http://2130706433/x")


# ── SEC-2: XSS injection in dashboard fields ──────────────────────────────


@pytest.mark.security
class TestXSSSanitisation:
    """SEC-2 — Malicious HTML/script tags are neutralised."""

    @pytest.mark.parametrize(
        "payload",
        [
            "<script>alert(1)</script>",
            '<img src=x onerror="alert(1)">',
            "javascript:alert(document.cookie)",
        ],
    )
    def test_sanitize_strips_or_escapes_xss(self, payload: str) -> None:
        result = _sanitize_variable_value(payload)
        # _sanitize_variable_value strips control chars and truncates, but
        # does not HTML-escape. The XML escaping layer (_xml_escape) handles
        # that before TwiML inclusion. We verify _xml_escape separately.
        escaped = _xml_escape(result)
        assert "<script>" not in escaped
        assert "onerror" not in escaped or "&lt;" in escaped

    def test_xml_escape_neutralises_script_tag(self) -> None:
        dangerous = "<script>alert(1)</script>"
        escaped = _xml_escape(dangerous)
        assert "<script>" not in escaped
        assert "&lt;script&gt;" in escaped

    def test_normal_text_unchanged(self) -> None:
        safe = "John Doe"
        assert _sanitize_variable_value(safe) == safe

    def test_normal_text_xml_escape_unchanged(self) -> None:
        safe = "Hello World"
        assert _xml_escape(safe) == safe


# ── SEC-3: E.164 phone number fuzzing ─────────────────────────────────────


@pytest.mark.security
class TestE164Validation:
    """SEC-3 — Reject malformed numbers, accept valid E.164."""

    @pytest.mark.parametrize(
        "bad_number",
        [
            "",
            "+",
            "+1",
            "+0000000000000000",  # starts with 0 after +
            "+123abc456",
            "null",
            "undefined",
            "+" + "1" * 10000,  # very long
            "+0123456789",  # leading zero after +
        ],
    )
    def test_rejects_invalid_numbers(self, bad_number: str) -> None:
        assert _validate_e164(bad_number) is False

    def test_accepts_valid_e164(self) -> None:
        assert _validate_e164("+14155552671") is True

    def test_accepts_minimum_length(self) -> None:
        # 7 digits after + is the minimum (E.164: +X XXXXXX)
        assert _validate_e164("+1234567") is True

    def test_accepts_maximum_length(self) -> None:
        # 15 digits after + is the maximum
        assert _validate_e164("+123456789012345") is True

    def test_rejects_over_maximum_length(self) -> None:
        assert _validate_e164("+1234567890123456") is False


# ── SEC-4: TwiML payload injection ────────────────────────────────────────


@pytest.mark.security
class TestTwiMLInjection:
    """SEC-4 — Injected TwiML verbs are escaped before inclusion."""

    def test_redirect_verb_escaped(self) -> None:
        malicious = "<Redirect>http://attacker.example/evil</Redirect>"
        escaped = _xml_escape(malicious)
        assert "<Redirect>" not in escaped
        assert "&lt;Redirect&gt;" in escaped

    def test_dial_injection_escaped(self) -> None:
        malicious = "</Say><Dial>+15551234567</Dial><Say>"
        escaped = _xml_escape(malicious)
        assert "<Dial>" not in escaped
        assert "&lt;Dial&gt;" in escaped

    def test_clean_text_produces_valid_output(self) -> None:
        clean = "Hello, how can I help you today?"
        result = _xml_escape(clean)
        assert result == clean

    def test_ampersand_escaped(self) -> None:
        text = "Tom & Jerry"
        result = _xml_escape(text)
        assert "&amp;" in result
        assert "& " not in result


# ── SEC-5: Secret leakage in logs and error messages ──────────────────────


@pytest.mark.security
class TestSecretLeakage:
    """SEC-5 — API keys/tokens must not appear in str/repr/error output."""

    FAKE_API_KEY = "sk_test_AbCdEfGhIjKlMnOpQrStUvWxYz123456"
    FAKE_TWILIO_TOKEN = "auth_token_XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    FAKE_OPENAI_KEY = "sk-proj-TestSecretKeyThatShouldNeverLeak0000000"

    def _secret_pattern(self) -> re.Pattern:
        """Match any of our fake secrets (20+ alnum chars)."""
        return re.compile(
            r"(?:"
            + re.escape(self.FAKE_API_KEY)
            + r"|"
            + re.escape(self.FAKE_TWILIO_TOKEN)
            + r"|"
            + re.escape(self.FAKE_OPENAI_KEY)
            + r")"
        )

    @pytest.mark.xfail(
        strict=True,
        reason=(
            "LocalConfig uses the default dataclass repr which includes all "
            "field values verbatim — twilio_token and openai_key are exposed. "
            "A custom __repr__ that masks secrets (e.g. '***') must be added "
            "before this test can pass. Marked strict so an accidental fix "
            "is caught and the xfail removed explicitly."
        ),
    )
    def test_local_config_repr_hides_secrets(self) -> None:
        config = LocalConfig(
            twilio_sid="ACtest000000000000000000000000000",
            twilio_token=self.FAKE_TWILIO_TOKEN,
            openai_key=self.FAKE_OPENAI_KEY,
        )
        output = repr(config)
        assert self.FAKE_TWILIO_TOKEN not in output, (
            "twilio_token must not appear verbatim in LocalConfig repr"
        )
        assert self.FAKE_OPENAI_KEY not in output, (
            "openai_key must not appear verbatim in LocalConfig repr"
        )

    def test_twilio_adapter_repr_masks_credentials(self) -> None:
        """TwilioAdapter.__repr__ must not contain full account_sid."""
        # We cannot import TwilioAdapter without the twilio package,
        # so we test the pattern from the source directly.
        sid = "ACtest000000000000000000000000000"
        masked = f"{sid[:6]}..." if len(sid) > 6 else "***"
        assert sid not in masked
        assert masked.startswith("ACtest")

    def test_error_message_is_useful(self) -> None:
        """Error messages must provide diagnostic info (not be empty/generic)."""
        from getpatter.exceptions import PatterConnectionError

        err = PatterConnectionError("Connection to backend failed: timeout after 30s")
        assert len(str(err)) > 10
        assert "timeout" in str(err).lower() or "connection" in str(err).lower()

    @pytest.mark.xfail(
        strict=True,
        reason=(
            "STTConfig uses the default dataclass repr which includes api_key "
            "verbatim, and Agent repr includes the nested STTConfig repr — "
            "so the API key is exposed in Agent repr. A custom __repr__ on "
            "STTConfig that masks api_key (e.g. '***') must be added before "
            "this test can pass. Marked strict so an accidental fix is caught "
            "and the xfail removed explicitly."
        ),
    )
    def test_agent_repr_does_not_leak_stt_key(self) -> None:
        from getpatter.models import STTConfig

        stt = STTConfig(provider="deepgram", api_key=self.FAKE_API_KEY)
        agent = Agent(
            system_prompt="test",
            stt=stt,
        )
        output = repr(agent)
        assert self.FAKE_API_KEY not in output, (
            "STTConfig.api_key must not appear verbatim in Agent repr"
        )
