"""Unit tests for public API methods."""

from __future__ import annotations

import pytest

from meridian_euid import (
    compute_check_character,
    encode,
    is_valid,
    parse,
    validate,
    EUIDChecksumError,
    EUIDFormatError,
    EUIDSandboxPrefixError,
)


class TestComputeCheckCharacter:
    """Tests for compute_check_character()."""

    def test_valid_production_payload(self) -> None:
        """Compute check char for valid production payload."""
        assert compute_check_character("TX1") == "C"

    def test_valid_sandbox_payload(self) -> None:
        """Compute check char for valid sandbox payload (no hyphen)."""
        assert compute_check_character("XTAP3V") == "X"

    def test_empty_payload_raises(self) -> None:
        """Empty payload must raise ValueError."""
        with pytest.raises(ValueError, match="non-empty"):
            compute_check_character("")

    def test_invalid_char_in_payload_raises(self) -> None:
        """Invalid characters in payload must raise ValueError."""
        with pytest.raises(ValueError, match="invalid character"):
            compute_check_character("TUB-1")  # hyphen not allowed

    def test_lowercase_in_payload_raises(self) -> None:
        """Lowercase in payload must raise ValueError."""
        with pytest.raises(ValueError, match="invalid character"):
            compute_check_character("tub1")

    def test_star_in_payload_raises(self) -> None:
        """Star in payload must raise ValueError."""
        with pytest.raises(ValueError, match="invalid character"):
            compute_check_character("TUB*1")


class TestIsValid:
    """Tests for is_valid()."""

    def test_valid_production_returns_true(self) -> None:
        assert is_valid("TX-1C", environment="production") is True

    def test_invalid_production_returns_false(self) -> None:
        assert is_valid("TX-1D", environment="production") is False

    def test_valid_sandbox_returns_true(self) -> None:
        assert (
            is_valid("X:TAP-3VX", environment="sandbox", allowed_sandbox_prefixes={"X"})
            is True
        )

    def test_invalid_sandbox_returns_false(self) -> None:
        assert (
            is_valid("X:TAP-3VY", environment="sandbox", allowed_sandbox_prefixes={"X"})
            is False
        )

    def test_sandbox_wrong_prefix_returns_false(self) -> None:
        assert (
            is_valid("X:TAP-3VX", environment="sandbox", allowed_sandbox_prefixes={"Y"})
            is False
        )

    def test_production_in_sandbox_context_returns_false(self) -> None:
        assert is_valid("TX-1C", environment="sandbox") is False

    def test_sandbox_in_production_context_returns_false(self) -> None:
        assert is_valid("X:TAP-3VX", environment="production") is False

    def test_lowercase_rejected(self) -> None:
        assert is_valid("tx-1c", environment="production") is False

    def test_one_char_category_rejected(self) -> None:
        assert is_valid("T-14", environment="production") is False

    def test_reserved_sandbox_prefix_rejected(self) -> None:
        assert is_valid("A:TX-1C", environment="sandbox") is False
        assert is_valid("G:TX-1C", environment="sandbox") is False


class TestValidate:
    """Tests for validate() edge cases not covered by vectors."""

    def test_returns_canonical_string(self) -> None:
        """validate() returns canonical EUID on success."""
        assert validate("TX-1C", environment="production") == "TX-1C"

    def test_lowercase_raises_format_error(self) -> None:
        """Lowercase input is rejected per SPEC.md §8.1."""
        with pytest.raises(EUIDFormatError, match="lowercase"):
            validate("tx-1c", environment="production")

    def test_non_string_raises_format_error(self) -> None:
        """Non-string input raises EUIDFormatError."""
        with pytest.raises(EUIDFormatError, match="must be a string"):
            validate(123, environment="production")  # type: ignore

    def test_sandbox_prefix_check_after_checksum(self) -> None:
        """Sandbox prefix check happens after checksum verification."""
        with pytest.raises(EUIDChecksumError):
            validate("X:TAP-3VY", environment="sandbox", allowed_sandbox_prefixes={"Y"})

    def test_sandbox_prefix_error_raised(self) -> None:
        """Correct checksum but wrong prefix raises EUIDSandboxPrefixError."""
        with pytest.raises(EUIDSandboxPrefixError):
            validate("X:TAP-3VX", environment="sandbox", allowed_sandbox_prefixes={"Y"})

    def test_sandbox_no_prefix_restriction(self) -> None:
        """Sandbox with no allowed_sandbox_prefixes accepts any valid prefix."""
        result = validate("X:TAP-3VX", environment="sandbox")
        assert result == "X:TAP-3VX"

    def test_unknown_environment_raises(self) -> None:
        """Unknown environment raises ValueError."""
        with pytest.raises(ValueError, match="unknown environment"):
            validate("TX-1C", environment="staging")  # type: ignore


class TestEncode:
    """Tests for encode()."""

    def test_production_basic(self) -> None:
        assert encode(1, "TX") == "TX-1C"

    def test_production_larger_integer(self) -> None:
        assert encode(12345, "DNA") == "DNA-C1SJ"

    def test_sandbox_basic(self) -> None:
        assert encode(123, "TAP", sandbox="X") == "X:TAP-3VX"

    def test_round_trip(self) -> None:
        """encode → parse → encode round trip."""
        for val, cat in [(1, "TX"), (10000, "TX"), (12345, "DNA"), (99999, "TAG")]:
            euid = encode(val, cat)
            result = parse(euid)
            assert result["integer"] == val
            assert result["category"] == cat
            assert result["sandbox"] is None

    def test_sandbox_round_trip(self) -> None:
        for pfx, cat, val in [("X", "TAP", 123), ("H", "DNA", 456), ("Z", "TX", 789)]:
            euid = encode(val, cat, sandbox=pfx)
            result = parse(euid)
            assert result["integer"] == val
            assert result["category"] == cat
            assert result["sandbox"] == pfx

    def test_category_too_short_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="2-3"):
            encode(1, "T")

    def test_category_too_long_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="2-3"):
            encode(1, "TXYZ")

    def test_reserved_sandbox_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="reserved"):
            encode(1, "TX", sandbox="A")

    def test_zero_integer_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="positive"):
            encode(0, "TX")

    def test_negative_integer_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="positive"):
            encode(-5, "TX")


class TestParse:
    """Tests for parse()."""

    def test_production(self) -> None:
        result = parse("TX-1C")
        assert result["sandbox"] is None
        assert result["category"] == "TX"
        assert result["body"] == "1"
        assert result["checksum"] == "C"
        assert result["integer"] == 1

    def test_sandbox(self) -> None:
        result = parse("X:TAP-3VX")
        assert result["sandbox"] == "X"
        assert result["category"] == "TAP"
        assert result["body"] == "3V"
        assert result["checksum"] == "X"
        assert result["integer"] == 123

    def test_invalid_checksum_raises(self) -> None:
        with pytest.raises(EUIDChecksumError):
            parse("TX-1D")

    def test_lowercase_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="lowercase"):
            parse("tx-1c")

    def test_non_string_raises(self) -> None:
        with pytest.raises(EUIDFormatError, match="must be a string"):
            parse(42)  # type: ignore


class TestRejectInput:
    """Tests for _reject_input() via validate/parse — covers input rejection paths."""

    def test_non_ascii_rejected(self) -> None:
        with pytest.raises(EUIDFormatError, match="ASCII"):
            validate("TX-1\u00e9", environment="production")

    def test_whitespace_rejected(self) -> None:
        with pytest.raises(EUIDFormatError, match="whitespace"):
            validate("TX -1C", environment="production")

    def test_non_ascii_in_parse(self) -> None:
        with pytest.raises(EUIDFormatError, match="ASCII"):
            parse("TX-1\u00e9")

    def test_whitespace_in_parse(self) -> None:
        with pytest.raises(EUIDFormatError, match="whitespace"):
            parse("TX -1C")


class TestSandboxParsingEdgeCases:
    """Tests for _parse_sandbox() edge cases — covers sandbox-specific paths."""

    def test_multiple_hyphens_in_sandbox(self) -> None:
        """Sandbox with multiple hyphens rejected (line 68)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TA-P-3VX", environment="sandbox")

    def test_colon_after_hyphen(self) -> None:
        """Colon appearing after hyphen rejected (line 70)."""
        with pytest.raises(EUIDFormatError):
            validate("TAP-3V:X", environment="sandbox")

    def test_sandbox_short_tail(self) -> None:
        """Sandbox body+checksum too short (line 81)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TX-A", environment="sandbox")

    def test_sandbox_leading_zero_body(self) -> None:
        """Sandbox body starting with 0 rejected (line 85)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TX-0AB", environment="sandbox")

    def test_sandbox_invalid_body_char(self) -> None:
        """Sandbox body containing forbidden char (line 87)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TX-1IA", environment="sandbox")

    def test_sandbox_invalid_checksum_char_lowercase(self) -> None:
        """Sandbox checksum char lowercase (caught by input rejection)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TX-1i", environment="sandbox")

    def test_sandbox_checksum_char_not_in_alphabet(self) -> None:
        """Sandbox checksum char uppercase but not in Crockford set (line 89)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TX-1I", environment="sandbox")

    def test_sandbox_one_char_category(self) -> None:
        """Single-char category in sandbox rejected (line 78)."""
        with pytest.raises(EUIDFormatError):
            validate("X:T-1C", environment="sandbox")

    def test_sandbox_four_char_category(self) -> None:
        """Four-char category in sandbox rejected (line 78)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TXYZ-1C", environment="sandbox")

    def test_sandbox_category_with_forbidden_letter(self) -> None:
        """Category containing I, L, O, or U in sandbox rejected (line 78)."""
        with pytest.raises(EUIDFormatError):
            validate("X:TI-1C", environment="sandbox")


class TestProductionParsingEdgeCases:
    """Tests for _parse_production() edge cases."""

    def test_production_short_tail(self) -> None:
        """Production body+checksum too short — single char after hyphen (line 51)."""
        with pytest.raises(EUIDFormatError):
            validate("TX-A", environment="production")


class TestEncodeEdgeCases:
    """Tests for encode() edge cases — covers validation branches."""

    def test_category_not_string_raises(self) -> None:
        """Non-string category (line 221)."""
        with pytest.raises(EUIDFormatError, match="category must be a string"):
            encode(1, 123)  # type: ignore

    def test_category_forbidden_letter_raises(self) -> None:
        """Category with letter not in Crockford alphabet (line 225)."""
        with pytest.raises(EUIDFormatError, match="Crockford"):
            encode(1, "TI")

    def test_sandbox_not_string_raises(self) -> None:
        """Non-string sandbox (line 230)."""
        with pytest.raises(EUIDFormatError, match="sandbox token must be a string"):
            encode(1, "TX", sandbox=9)  # type: ignore

    def test_sandbox_multi_char_raises(self) -> None:
        """Multi-char sandbox (line 232)."""
        with pytest.raises(EUIDFormatError, match="single character"):
            encode(1, "TX", sandbox="XY")

    def test_sandbox_invalid_letter_raises(self) -> None:
        """Sandbox letter not in valid set and not reserved (line 238)."""
        with pytest.raises(EUIDFormatError, match="not a valid"):
            encode(1, "TX", sandbox="I")

    def test_integer_bool_raises(self) -> None:
        """Boolean input rejected even though bool is subclass of int (line 244)."""
        with pytest.raises(EUIDFormatError, match="positive int"):
            encode(True, "TX")  # type: ignore

    def test_integer_float_raises(self) -> None:
        """Float input rejected (line 244)."""
        with pytest.raises(EUIDFormatError, match="positive int"):
            encode(1.5, "TX")  # type: ignore


class TestPrivateHelpers:
    """Tests for private helpers that are hard to reach through public API."""

    def test_int_to_base32_zero_raises(self) -> None:
        """_int_to_base32(0) raises (line 181 — defensive guard)."""
        from meridian_euid.euid import _int_to_base32

        with pytest.raises(EUIDFormatError, match="positive"):
            _int_to_base32(0)

    def test_base32_to_int_invalid_char_raises(self) -> None:
        """_base32_to_int with invalid char (line 195 — defensive guard)."""
        from meridian_euid.euid import _base32_to_int

        with pytest.raises(EUIDFormatError, match="invalid Base32 character"):
            _base32_to_int("1I2")


class TestMod32EdgeCases:
    """Cover mod32.py edge cases."""

    def test_non_ascii_payload_raises(self) -> None:
        """Non-ASCII payload rejected (mod32.py line 22)."""
        from meridian_euid.mod32 import mod32_check_character

        with pytest.raises(ValueError, match="ASCII"):
            mod32_check_character("TX\u00e9")
