"""Unit tests for mitmproxy_helpers (no mitmproxy dependency required)."""

import json
from pathlib import Path

from spens.data.mitmproxy_helpers import (
    READY_MARKER_FILENAME,
    REDACTED,
    StreamingSSEParser,
    TraceWriter,
    decode_stream_bytes,
    host_allowed,
    host_matches,
    hostname,
    is_allowed,
    is_secret_authorized,
    mask_secrets,
    matches,
    parse_json,
    parse_sse,
    should_capture,
    timestamp,
    write_ready_marker,
)

# -- parse_json ---------------------------------------------------------


def test_parse_json_valid_str() -> None:
    assert parse_json('{"a": 1}') == {"a": 1}


def test_parse_json_valid_bytes() -> None:
    assert parse_json(b'{"b": 2}') == {"b": 2}


def test_parse_json_none() -> None:
    assert parse_json(None) is None


def test_parse_json_invalid_str() -> None:
    assert parse_json("not json") is None


def test_parse_json_invalid_bytes() -> None:
    assert parse_json(b"\xff\xfe") is None


def test_parse_json_array() -> None:
    assert parse_json("[1, 2, 3]") == [1, 2, 3]


def test_parse_json_empty_str() -> None:
    assert parse_json("") is None


# -- parse_sse ----------------------------------------------------------


def test_parse_sse_basic() -> None:
    content = b'data: {"choices": [{"delta": {"content": "hi"}}]}\n'
    chunks = parse_sse(content)
    assert len(chunks) == 1
    assert chunks[0]["choices"][0]["delta"]["content"] == "hi"


def test_parse_sse_multiple_lines() -> None:
    content = b'data: {"a": 1}\ndata: {"b": 2}\n'
    chunks = parse_sse(content)
    assert len(chunks) == 2
    assert chunks[0] == {"a": 1}
    assert chunks[1] == {"b": 2}


def test_parse_sse_done_sentinel() -> None:
    content = b'data: {"a": 1}\ndata: [DONE]\n'
    chunks = parse_sse(content)
    assert len(chunks) == 1
    assert chunks[0] == {"a": 1}


def test_parse_sse_empty() -> None:
    assert parse_sse(b"") == []


def test_parse_sse_non_data_lines() -> None:
    content = b'event: chunk\ndata: {"a": 1}\nid: 42\n'
    chunks = parse_sse(content)
    assert len(chunks) == 1
    assert chunks[0] == {"a": 1}


def test_parse_sse_invalid_json_skipped() -> None:
    content = b"data: not json\ndata: {\"ok\": true}\n"
    chunks = parse_sse(content)
    assert len(chunks) == 1
    assert chunks[0] == {"ok": True}


def test_parse_sse_crlf_line_endings() -> None:
    content = b'data: {"a": 1}\r\ndata: {"b": 2}\r\n'
    chunks = parse_sse(content)
    assert len(chunks) == 2
    assert chunks[0] == {"a": 1}
    assert chunks[1] == {"b": 2}


def test_parse_see_non_dict_json_skipped() -> None:
    content = b'data: [1, 2]\ndata: {"ok": true}\n'
    chunks = parse_sse(content)
    assert len(chunks) == 1
    assert chunks[0] == {"ok": True}


# -- StreamingSSEParser -------------------------------------------------


def test_streaming_parser_single_chunk() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b'data: {"a": 1}\n')
    assert len(events) == 1
    assert events[0] == {"a": 1}


def test_streaming_parser_multi_byte_utf8_split_across_chunks() -> None:
    """Regression: per-chunk str decoding replaced each half of a multi-byte
    UTF-8 sequence (split across chunk boundaries) with U+FFFD, corrupting
    every text delta that contained non-ASCII characters."""
    import json
    parser = StreamingSSEParser()
    payload = json.dumps({"text": "caf\u00e9 \U0001F996"}, ensure_ascii=False)
    raw = ("data: " + payload + "\n").encode("utf-8")
    mid = len(raw) // 2
    assert parser.feed(raw[:mid]) == []
    events = parser.feed(raw[mid:])
    assert events == [{"text": "caf\u00e9 \U0001F996"}]


def test_streaming_parser_split_across_chunks() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b'data: {"a": ')
    assert events == []
    events = parser.feed(b'1}\n')
    assert len(events) == 1
    assert events[0] == {"a": 1}


def test_streaming_parser_multiple_lines_one_chunk() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b'data: {"a": 1}\ndata: {"b": 2}\n')
    assert len(events) == 2
    assert events[0] == {"a": 1}
    assert events[1] == {"b": 2}


def test_streaming_parser_done_sentinel() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b'data: {"a": 1}\ndata: [DONE]\n')
    assert len(events) == 1
    assert events[0] == {"a": 1}


def test_streaming_parser_empty_chunk() -> None:
    parser = StreamingSSEParser()
    assert parser.feed(b"") == []


def test_streaming_parser_partial_then_complete() -> None:
    parser = StreamingSSEParser()
    assert parser.feed(b"data: ") == []
    assert parser.feed(b'{"x":') == []
    assert parser.feed(b' 10}\n') == [{"x": 10}]


def test_streaming_parser_crlf() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b'data: {"a": 1}\r\ndata: {"b": 2}\r\n')
    assert len(events) == 2


def test_streaming_parser_non_data_lines_ignored() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b'event: foo\ndata: {"a": 1}\nid: 1\n')
    assert len(events) == 1
    assert events[0] == {"a": 1}


def test_streaming_parser_invalid_json_skipped() -> None:
    parser = StreamingSSEParser()
    events = parser.feed(b"data: broken\ndata: {\"ok\": true}\n")
    assert len(events) == 1
    assert events[0] == {"ok": True}


def test_streaming_parser_equivalent_to_batch() -> None:
    content = b'data: {"a": 1}\ndata: {"b": 2}\ndata: [DONE]\n'
    batch = parse_sse(content)

    parser = StreamingSSEParser()
    streaming = parser.feed(content)

    assert streaming == batch


def test_streaming_parser_byte_by_byte() -> None:
    content = b'data: {"a": 1}\n'
    parser = StreamingSSEParser()
    all_events: list = []
    for byte in content:
        all_events.extend(parser.feed(bytes([byte])))
    assert all_events == [{"a": 1}]


# -- matches / should_capture / is_allowed ------------------------------


def test_matches_glob() -> None:
    assert matches("https://api.openai.com/v1/chat", "*api.openai.com*")
    assert not matches("https://example.com", "*api.openai.com*")


def test_matches_wildcard() -> None:
    assert matches("anything", "*")


def test_should_capture_true() -> None:
    assert should_capture("https://api.openai.com/v1/chat", ["*api.openai.com*"])


def test_should_capture_false() -> None:
    assert not should_capture("https://example.com", ["*api.openai.com*"])


def test_should_capture_empty_patterns() -> None:
    assert not should_capture("https://example.com", [])


def test_should_capture_excluded() -> None:
    assert not should_capture(
        "https://models.opencode.ai/api.json",
        ["*opencode.ai*"],
        exclude_patterns=["*models.opencode.ai*"],
    )


def test_should_capture_not_excluded() -> None:
    assert should_capture(
        "https://opencode.ai/zen/v1/chat/completions",
        ["*opencode.ai*"],
        exclude_patterns=["*models.opencode.ai*"],
    )


def test_should_capture_no_excludes() -> None:
    assert should_capture(
        "https://models.opencode.ai/api.json",
        ["*opencode.ai*"],
    )


def test_is_allowed_no_rules() -> None:
    assert is_allowed("https://anything.com", "GET", [])


def test_is_allowed_matching_rule_allowed() -> None:
    rules = [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert is_allowed("https://api.github.com/repos", "GET", rules)


def test_is_allowed_matching_rule_blocked() -> None:
    rules = [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert not is_allowed("https://api.github.com/repos", "POST", rules)


def test_is_allowed_wildcard_method() -> None:
    rules = [{"pattern": "*api.github.com*", "allow": ["*"]}]
    assert is_allowed("https://api.github.com/repos", "DELETE", rules)


def test_is_allowed_non_matching_rule_fails_closed() -> None:
    # A hostname not covered by any rule is denied (fail closed).
    rules = [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert not is_allowed("https://other.com", "POST", rules)


def test_is_allowed_matches_hostname_not_path() -> None:
    # A rule must match the hostname, not merely appear in the URL path/query.
    rules = [{"pattern": "*api.github.com*", "allow": ["*"]}]
    assert not is_allowed("https://evil.com/api.github.com/x", "GET", rules)


def test_is_allowed_rejects_domain_glued_suffix() -> None:
    # api.github.com.attacker.example must not be treated as api.github.com.
    rules = [{"pattern": "*api.github.com*", "allow": ["*"]}]
    assert not is_allowed("https://api.github.com.attacker.example/repos", "GET", rules)


def test_is_allowed_rejects_prefixed_label() -> None:
    # evil-api.github.com is not api.github.com nor a subdomain of it.
    rules = [{"pattern": "*api.github.com*", "allow": ["*"]}]
    assert not is_allowed("https://evil-api.github.com/repos", "GET", rules)


def test_is_allowed_case_insensitive() -> None:
    rules = [{"pattern": "*api.github.com*", "allow": ["get"]}]
    assert is_allowed("https://api.github.com/repos", "GET", rules)


# -- host_allowed (CONNECT tunnel targets) --------------------------------


def test_host_allowed_no_rules() -> None:
    # No policy configured -> allow (same semantics as is_allowed).
    assert host_allowed("example.com:443", [])


def test_host_allowed_matching_rule() -> None:
    # CONNECT targets arrive as authority-form "host:port" strings.
    rules = [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert host_allowed("api.github.com:443", rules)
    assert host_allowed("https://api.github.com/repos", rules)


def test_host_allowed_non_matching_fails_closed() -> None:
    rules = [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert not host_allowed("evil.example.com:22", rules)
    assert not host_allowed("1.2.3.4:22", rules)


def test_host_allowed_ignores_method_list() -> None:
    # The CONNECT method is never in a rule's allow list; method-level
    # enforcement happens on the HTTP requests sent through the tunnel.
    rules = [{"pattern": "*api.github.com*", "allow": ["GET"]}]
    assert host_allowed("api.github.com:443", rules)


def test_host_allowed_rejects_domain_glued_suffix() -> None:
    rules = [{"pattern": "*api.github.com*", "allow": ["*"]}]
    assert not host_allowed("api.github.com.attacker.example:443", rules)


def test_host_allowed_multiple_rules() -> None:
    rules = [
        {"pattern": "*api.anthropic.com*", "allow": ["*"]},
        {"pattern": "*api.openai.com*", "allow": ["*"]},
    ]
    assert host_allowed("api.anthropic.com:443", rules)
    assert host_allowed("api.openai.com:443", rules)
    assert not host_allowed("api.github.com:443", rules)


def test_host_allowed_ip_target_fails_closed() -> None:
    # An IP-literal CONNECT target matches no domain pattern.
    rules = [{"pattern": "*api.github.com*", "allow": ["*"]}]
    assert not host_allowed("140.82.121.4:443", rules)


# -- is_secret_authorized -----------------------------------------------


def test_secret_authorized_matching_domain() -> None:
    assert is_secret_authorized(
        "https://api.anthropic.com/v1/messages",
        ["*api.anthropic.com*"],
    )


def test_secret_authorized_not_matching_domain() -> None:
    assert not is_secret_authorized(
        "https://evil.example.com/exfil",
        ["*api.anthropic.com*"],
    )


def test_secret_authorized_multiple_patterns() -> None:
    patterns = ["*api.anthropic.com*", "*api.openai.com*"]
    assert is_secret_authorized("https://api.anthropic.com/v1/messages", patterns)
    assert is_secret_authorized("https://api.openai.com/v1/chat/completions", patterns)
    assert not is_secret_authorized("https://api.github.com/", patterns)


def test_secret_authorized_empty_list_denies_all() -> None:
    assert not is_secret_authorized("https://api.anthropic.com/v1/messages", [])


def test_secret_authorized_missing_list_denies_all() -> None:
    assert not is_secret_authorized("https://api.anthropic.com/v1/messages", None)


def test_secret_authorized_subdomain_scoping() -> None:
    """A pattern scoped to a subdomain must not match sibling subdomains."""
    assert not is_secret_authorized(
        "https://evil-anthropic.com.example.org/",
        ["*api.anthropic.com*"],
    )


def test_secret_authorized_wildcard_all() -> None:
    assert is_secret_authorized("https://anything.example.com/", ["*"])


def test_secret_authorized_matches_hostname_not_path() -> None:
    # The domain must be the actual host, not smuggled into the path/query.
    assert not is_secret_authorized(
        "https://evil.com/?redir=api.anthropic.com",
        ["*api.anthropic.com*"],
    )


def test_secret_authorized_rejects_domain_glued_suffix() -> None:
    # The classic fnmatch bypass: the authorized domain embedded as a prefix
    # of an attacker-controlled hostname.  Suffix-at-label-boundary
    # semantics must reject it.
    assert not is_secret_authorized(
        "https://api.anthropic.com.attacker.example/v1/messages",
        ["*api.anthropic.com*"],
    )


def test_secret_authorized_rejects_prefixed_label() -> None:
    assert not is_secret_authorized(
        "https://evil-anthropic.com/",
        ["*anthropic.com*"],
    )


def test_secret_authorized_subdomain_of_authorized_domain() -> None:
    # A pattern naming a bare domain covers the domain and its subdomains.
    assert is_secret_authorized(
        "https://api.anthropic.com/v1/messages",
        ["*anthropic.com*"],
    )
    assert is_secret_authorized(
        "https://anthropic.com/",
        ["*anthropic.com*"],
    )


def test_secret_authorized_subdomain_pattern_does_not_cover_apex() -> None:
    # A pattern naming a specific subdomain only covers that host and its
    # own subdomains.
    assert is_secret_authorized(
        "https://api.anthropic.com/v1/messages",
        ["*api.anthropic.com*"],
    )
    assert is_secret_authorized(
        "https://edge.api.anthropic.com/v1/messages",
        ["*api.anthropic.com*"],
    )
    assert not is_secret_authorized(
        "https://anthropic.com/",
        ["*api.anthropic.com*"],
    )


# -- hostname / host_matches / mask_secrets -----------------------------


def test_hostname_extracts_host() -> None:
    assert hostname("https://api.anthropic.com/v1/messages?x=1") == "api.anthropic.com"


def test_hostname_lowercases() -> None:
    assert hostname("https://API.Anthropic.COM/v1") == "api.anthropic.com"


def test_hostname_bare_fallback() -> None:
    assert hostname("api.anthropic.com") == "api.anthropic.com"


def test_hostname_authority_form_strips_port() -> None:
    # mitmproxy presents a CONNECT target as a bare "host:port" authority;
    # urlsplit mis-parses that ("example.com" becomes the URI scheme), so
    # the fallback must strip the port or domain rules would never match.
    assert hostname("api.anthropic.com:443") == "api.anthropic.com"
    assert hostname("1.2.3.4:22") == "1.2.3.4"
    assert hostname("api.anthropic.com:abc") == "api.anthropic.com:abc"


def test_hostname_bare_ipv6_kept_verbatim() -> None:
    # No port stripping for colon-heavy IPv6 literals: they fail closed
    # rather than being mangled into a matchable string.
    assert hostname("[::1]:22") == "[::1]:22"
    assert hostname("::1") == "::1"


def test_host_matches_true() -> None:
    assert host_matches("https://api.openai.com/v1/chat", "*api.openai.com*")


def test_host_matches_ignores_path() -> None:
    assert not host_matches("https://evil.com/api.openai.com", "*api.openai.com*")


def test_host_matches_suffix_not_substring() -> None:
    # fnmatch would accept these because the trailing '*' allows anything to
    # follow the domain; suffix semantics must reject them.
    assert not host_matches(
        "https://api.anthropic.com.attacker.example/", "*api.anthropic.com*"
    )
    assert not host_matches(
        "https://api.anthropic.com.attacker.example/", "*.anthropic.com"
    )


def test_host_matches_label_boundary() -> None:
    # The domain must end at a dot boundary, not in the middle of a label.
    assert not host_matches("https://evil-anthropic.com/", "*anthropic.com")
    assert not host_matches("https://xanthropic.com/", "*.anthropic.com")
    assert host_matches("https://api.anthropic.com/", "*.anthropic.com")
    assert host_matches("https://anthropic.com/", "*.anthropic.com")


def test_host_matches_bare_domain() -> None:
    # Patterns without wildcards get the same suffix semantics.
    assert host_matches("https://api.anthropic.com/", "anthropic.com")
    assert host_matches("https://anthropic.com/", "anthropic.com")
    assert not host_matches("https://anthropic.com.evil.io/", "anthropic.com")


def test_host_matches_bare_wildcard_matches_all() -> None:
    assert host_matches("https://anything.example.com/", "*")
    assert host_matches("https://anything.example.com/", "**")


def test_host_matches_case_insensitive_pattern() -> None:
    assert host_matches("https://api.anthropic.com/", "*.Anthropic.COM")


def test_host_matches_trailing_dot_hostname() -> None:
    # Fully-qualified hostname form still matches.
    assert host_matches("https://api.anthropic.com./", "*.anthropic.com")


def test_host_matches_internal_wildcard_fails_closed() -> None:
    # Wildcards inside a pattern are not supported and must not match.
    assert not host_matches("https://api.anthropic.com/", "api.*.com")


def test_mask_secrets_replaces() -> None:
    masked = mask_secrets("https://api/x?key=supersecret", ["supersecret"])
    assert "supersecret" not in masked
    assert REDACTED in masked


def test_mask_secrets_multiple() -> None:
    masked = mask_secrets("a=one b=two", ["one", "two"])
    assert "one" not in masked and "two" not in masked


def test_mask_secrets_no_secrets() -> None:
    assert mask_secrets("https://api/x", []) == "https://api/x"
    assert mask_secrets("https://api/x", None) == "https://api/x"


def test_mask_secrets_none_text() -> None:
    assert mask_secrets(None, ["s"]) is None


def test_mask_secrets_ignores_empty_secret() -> None:
    assert mask_secrets("https://api/x", [""]) == "https://api/x"


# -- TraceWriter --------------------------------------------------------


def test_trace_writer_flush_writes_all(tmp_path: Path) -> None:
    trace_file = tmp_path / "captured.jsonl"
    writer = TraceWriter(trace_file)
    writer.add({"type": "request", "id": "r1"})
    writer.add({"type": "response_chunk", "request_id": "r1"})
    writer.add({"type": "response_meta", "request_id": "r1"})
    writer.flush()

    lines = trace_file.read_text(encoding="utf-8").strip().split("\n")
    assert len(lines) == 3
    assert json.loads(lines[0])["type"] == "request"
    assert json.loads(lines[1])["type"] == "response_chunk"
    assert json.loads(lines[2])["type"] == "response_meta"


def test_trace_writer_flush_clears_buffer(tmp_path: Path) -> None:
    trace_file = tmp_path / "captured.jsonl"
    writer = TraceWriter(trace_file)
    writer.add({"type": "request", "id": "r1"})
    writer.flush()

    writer.add({"type": "response", "request_id": "r1"})
    writer.flush()

    lines = trace_file.read_text(encoding="utf-8").strip().split("\n")
    assert len(lines) == 2


def test_trace_writer_flush_empty_no_write(tmp_path: Path) -> None:
    trace_file = tmp_path / "captured.jsonl"
    writer = TraceWriter(trace_file)
    writer.flush()
    assert not trace_file.exists()


def test_trace_writer_append_mode(tmp_path: Path) -> None:
    trace_file = tmp_path / "captured.jsonl"
    trace_file.write_text('{"existing": true}\n', encoding="utf-8")

    writer = TraceWriter(trace_file)
    writer.add({"type": "request", "id": "r2"})
    writer.flush()

    lines = trace_file.read_text(encoding="utf-8").strip().split("\n")
    assert len(lines) == 2
    assert json.loads(lines[0])["existing"] is True
    assert json.loads(lines[1])["id"] == "r2"


# -- timestamp ----------------------------------------------------------


def test_timestamp_format() -> None:
    ts = timestamp()
    assert ts.endswith("Z")
    assert "T" in ts


# -- readiness marker ---------------------------------------------------


def test_write_ready_marker(tmp_path: Path) -> None:
    marker = write_ready_marker(tmp_path / "nested" / "certs")
    assert marker.name == READY_MARKER_FILENAME
    assert marker.is_file()
    # contents are a timestamp, useful for debugging startup races
    content = marker.read_text(encoding="utf-8").strip()
    assert content.endswith("Z")
    assert "T" in content


def test_write_ready_marker_overwrites_stale_marker(tmp_path: Path) -> None:
    first = write_ready_marker(tmp_path)
    second = write_ready_marker(tmp_path)
    assert first == second
    assert second.is_file()


# -- domain_matches (the shared rule behind host_matches and DNS policy) -----


def test_domain_matches_suffix_at_label_boundary() -> None:
    from spens.data.mitmproxy_helpers import domain_matches

    assert domain_matches("api.anthropic.com", "*api.anthropic.com*")
    assert domain_matches("eu.api.anthropic.com", "api.anthropic.com")
    assert domain_matches("pypi.org", "*.pypi.org")
    assert not domain_matches("anthropic.com", "api.anthropic.com")
    assert not domain_matches("evil-anthropic.com", "anthropic.com")
    assert not domain_matches("api.anthropic.com.attacker.example", "api.anthropic.com")


def test_domain_matches_normalises_host_and_pattern() -> None:
    from spens.data.mitmproxy_helpers import domain_matches

    assert domain_matches("API.Anthropic.COM", "anthropic.com")
    assert domain_matches("api.anthropic.com.", "  anthropic.com  ")
    assert not domain_matches("", "anthropic.com")
    assert not domain_matches(None, "anthropic.com")


def test_domain_matches_lone_wildcard_allows_everything() -> None:
    from spens.data.mitmproxy_helpers import domain_matches

    assert domain_matches("anything.example", "*")
    assert domain_matches("anything.example", "**")


# -- decode_stream_bytes --------------------------------------------------


def test_decode_stream_bytes_identity_passthrough() -> None:
    raw = b'data: {"a": 1}\n'
    assert decode_stream_bytes(raw, "") == raw
    assert decode_stream_bytes(raw, "identity") == raw
    assert decode_stream_bytes(raw, None) == raw


def test_decode_stream_bytes_gzip() -> None:
    import gzip
    raw = b'data: {"text": "caf\xe9"}\n'
    compressed = gzip.compress(raw)
    assert decode_stream_bytes(compressed, "gzip") == raw
    # The declared encoding is wrong/missing: the other candidates are tried.
    assert decode_stream_bytes(compressed, "") == raw


def test_decode_stream_bytes_garbage_returned_unchanged() -> None:
    """Bytes that decode as nothing (not even gzip) must not be mangled."""
    raw = b"\x00\x01\x02not-a-known-encoding"
    assert decode_stream_bytes(raw, "gzip") == raw
