from __future__ import annotations

import re
from dataclasses import dataclass
from typing import AbstractSet, Literal, Optional

from .exceptions import EUIDChecksumError, EUIDFormatError, EUIDSandboxPrefixError
from .mod32 import ALPHABET_32, VALUE_32, mod32_check_character

Environment = Literal["production", "sandbox"]

_ALNUM32_RE = re.compile(r"^[0-9A-HJ-KMNP-TV-Z]+$")
_LETTERS32_RE = re.compile(r"^[A-HJ-KMNP-TV-Z]+$")
_LOWERCASE_RE = re.compile(r"[a-z]")

# Sandbox prefixes A-G are reserved.  Available: H,J,K,M,N,P,Q,R,S,T,V,W,X,Y,Z
_RESERVED_SANDBOX = frozenset("ABCDEFG")
_VALID_SANDBOX = frozenset("HJKMNPQRSTVWXYZ")

# Public constants for downstream consumers (e.g. governance tooling).
RESERVED_SANDBOX_PREFIXES: frozenset[str] = _RESERVED_SANDBOX
VALID_SANDBOX_PREFIXES: frozenset[str] = _VALID_SANDBOX


def validate_category(category: str) -> str:
    """Validate a Meridian EUID category string.

    A valid category is 2-3 uppercase characters from the Crockford Base32
    letter subset (no digits, no forbidden letters I/L/O/U).

    Args:
        category: The category string to validate.

    Returns:
        The validated category string (unchanged -- no normalization).

    Raises:
        EUIDFormatError: If the category is invalid.
    """
    if not isinstance(category, str):
        raise EUIDFormatError("category must be a string")
    if not (2 <= len(category) <= 3):
        raise EUIDFormatError("category must be 2-3 characters")
    if not _LETTERS32_RE.fullmatch(category):
        raise EUIDFormatError(
            "category must be uppercase Crockford Base32 letters (no I/L/O/U)"
        )
    return category


def validate_sandbox_token(token: str) -> str:
    """Validate a Meridian sandbox token (prefix letter).

    A valid sandbox token is a single uppercase Crockford letter that is NOT
    in the reserved set A-G.  The 15 available tokens are:
    H, J, K, M, N, P, Q, R, S, T, V, W, X, Y, Z.

    The special value ``"prod"`` is **not** handled here; callers that accept
    ``"prod"`` as a namespace mode indicator should check for it before
    calling this function.

    Args:
        token: The sandbox token to validate.

    Returns:
        The validated token string (unchanged -- no normalization).

    Raises:
        EUIDFormatError: If the token is not a valid sandbox prefix.
    """
    if not isinstance(token, str):
        raise EUIDFormatError("sandbox token must be a string")
    if len(token) != 1:
        raise EUIDFormatError("sandbox token must be a single character")
    if token in _RESERVED_SANDBOX:
        raise EUIDFormatError(f"sandbox token {token!r} is reserved (A-G are reserved)")
    if token not in _VALID_SANDBOX:
        raise EUIDFormatError(
            f"sandbox token {token!r} is not a valid Crockford letter"
        )
    return token


@dataclass(frozen=True, slots=True)
class _ParsedEuid:
    sandbox: str | None
    category: str
    body: str
    checksum: str


def _reject_input(euid: str) -> str:
    """Step 1: Input rejection — reject non-ASCII, whitespace, lowercase."""
    if not euid.isascii():
        raise EUIDFormatError("EUID must be ASCII")
    if any(ch.isspace() for ch in euid):
        raise EUIDFormatError("whitespace is not permitted in EUID")
    if _LOWERCASE_RE.search(euid):
        raise EUIDFormatError("lowercase letters are not permitted in EUID")
    return euid


def _parse_production(euid: str) -> _ParsedEuid:
    if ":" in euid:
        raise EUIDFormatError("invalid production EUID syntax")
    if euid.count("-") != 1:
        raise EUIDFormatError("invalid production EUID syntax")

    category, tail = euid.split("-", 1)
    if not (2 <= len(category) <= 3) or not _LETTERS32_RE.fullmatch(category):
        raise EUIDFormatError("invalid production EUID syntax")

    if len(tail) < 2:
        raise EUIDFormatError("invalid production EUID syntax")
    body, checksum = tail[:-1], tail[-1]

    if not body or body[0] == "0":
        raise EUIDFormatError("invalid production EUID syntax")
    if not _ALNUM32_RE.fullmatch(body):
        raise EUIDFormatError("invalid production EUID syntax")
    if checksum not in ALPHABET_32:
        raise EUIDFormatError("invalid production EUID syntax")

    return _ParsedEuid(sandbox=None, category=category, body=body, checksum=checksum)


def _parse_sandbox(euid: str) -> _ParsedEuid:
    if euid.count(":") != 1:
        raise EUIDFormatError("invalid sandbox EUID syntax")
    if euid.count("-") != 1:
        raise EUIDFormatError("invalid sandbox EUID syntax")
    if euid.index(":") > euid.index("-"):
        raise EUIDFormatError("invalid sandbox EUID syntax")

    sandbox, rest = euid.split(":", 1)
    if len(sandbox) != 1 or sandbox not in _VALID_SANDBOX:
        raise EUIDFormatError("invalid sandbox EUID syntax")

    category, tail = rest.split("-", 1)
    if not (2 <= len(category) <= 3) or not _LETTERS32_RE.fullmatch(category):
        raise EUIDFormatError("invalid sandbox EUID syntax")

    if len(tail) < 2:
        raise EUIDFormatError("invalid sandbox EUID syntax")
    body, checksum = tail[:-1], tail[-1]

    if not body or body[0] == "0":
        raise EUIDFormatError("invalid sandbox EUID syntax")
    if not _ALNUM32_RE.fullmatch(body):
        raise EUIDFormatError("invalid sandbox EUID syntax")
    if checksum not in ALPHABET_32:
        raise EUIDFormatError("invalid sandbox EUID syntax")

    return _ParsedEuid(sandbox=sandbox, category=category, body=body, checksum=checksum)


def compute_check_character(payload: str) -> str:
    """Compute the Meridian v2 check character for a checksum payload.

    Per SPEC.md §7.3, the checksum payload is:

    - Production: CATEGORY + BODY
    - Sandbox: SANDBOX + CATEGORY + BODY

    Payload MUST be non-empty and MUST contain only canonical Crockford Base32
    characters (no delimiters). This function is strict and does not
    normalize.
    """

    return mod32_check_character(payload)


def validate(
    euid: str,
    *,
    environment: Environment,
    allowed_sandbox_prefixes: Optional[AbstractSet[str]] = None,
) -> str:
    """Validate a Meridian EUID.

    - Returns the canonical EUID string if valid.
    - Raises a typed exception if invalid.

    Lowercase input is rejected per SPEC.md §8.1.
    """

    if not isinstance(euid, str):
        raise EUIDFormatError("EUID must be a string")

    euid_norm = _reject_input(euid)

    if environment == "production":
        parsed = _parse_production(euid_norm)

        payload = f"{parsed.category}{parsed.body}"
        expected = mod32_check_character(payload)
        if parsed.checksum != expected:
            raise EUIDChecksumError("checksum mismatch")

        return f"{parsed.category}-{parsed.body}{parsed.checksum}"

    if environment == "sandbox":
        parsed = _parse_sandbox(euid_norm)

        payload = f"{parsed.sandbox}{parsed.category}{parsed.body}"
        expected = mod32_check_character(payload)
        if parsed.checksum != expected:
            raise EUIDChecksumError("checksum mismatch")

        # Sandbox prefix allowlist check (after syntax + checksum).
        if (
            allowed_sandbox_prefixes is not None
            and parsed.sandbox not in allowed_sandbox_prefixes
        ):
            raise EUIDSandboxPrefixError("sandbox prefix not allowed in this context")

        return f"{parsed.sandbox}:{parsed.category}-{parsed.body}{parsed.checksum}"

    raise ValueError(f"unknown environment: {environment!r}")


def is_valid(
    euid: str,
    *,
    environment: Environment,
    allowed_sandbox_prefixes: Optional[AbstractSet[str]] = None,
) -> bool:
    """Return True if `euid` is valid for the given context, else False."""

    try:
        validate(
            euid,
            environment=environment,
            allowed_sandbox_prefixes=allowed_sandbox_prefixes,
        )
        return True
    except Exception:
        return False


def _int_to_base32(n: int) -> str:
    """Encode a positive integer to Crockford Base32 (no padding, no leading zeros)."""
    if n <= 0:
        raise EUIDFormatError("integer must be positive")
    result = []
    while n > 0:
        result.append(ALPHABET_32[n % 32])
        n //= 32
    return "".join(reversed(result))


def _base32_to_int(s: str) -> int:
    """Decode a Crockford Base32 string to a positive integer."""
    n = 0
    for ch in s:
        v = VALUE_32.get(ch)
        if v is None:
            raise EUIDFormatError(f"invalid Base32 character: {ch!r}")
        n = n * 32 + v
    return n


def encode(
    integer: int,
    category: str,
    *,
    sandbox: Optional[str] = None,
) -> str:
    """Encode an integer as a Meridian EUID.

    Args:
        integer: Positive integer to encode as the BODY.
        category: 2-3 uppercase letters from the Crockford alphabet.
        sandbox: Optional single sandbox prefix letter (H,J,K,M,N,P,Q,R,S,T,V,W,X,Y,Z).

    Returns:
        The complete EUID string with checksum.

    Raises:
        EUIDFormatError: If any input is invalid.
    """
    # Validate category (delegate to public helper)
    validate_category(category)

    # Validate sandbox prefix (delegate to public helper)
    if sandbox is not None:
        validate_sandbox_token(sandbox)

    # Validate and encode integer
    if not isinstance(integer, int) or isinstance(integer, bool):
        raise EUIDFormatError("integer must be a positive int")
    if integer <= 0:
        raise EUIDFormatError("integer must be positive")

    body = _int_to_base32(integer)

    # Compute checksum payload
    if sandbox is not None:
        payload = f"{sandbox}{category}{body}"
    else:
        payload = f"{category}{body}"

    checksum = mod32_check_character(payload)

    # Assemble EUID
    if sandbox is not None:
        return f"{sandbox}:{category}-{body}{checksum}"
    return f"{category}-{body}{checksum}"


def parse(euid: str) -> dict[str, object]:
    """Parse a validated EUID into its components.

    Args:
        euid: A valid EUID string (uppercase, canonical form).

    Returns:
        A dict with keys: sandbox (str|None), category (str), body (str),
        checksum (str), integer (int).

    Raises:
        EUIDFormatError: If the EUID is malformed.
        EUIDChecksumError: If the checksum is invalid.
    """
    if not isinstance(euid, str):
        raise EUIDFormatError("EUID must be a string")

    _reject_input(euid)

    # Determine environment by presence of ':'
    if ":" in euid:
        parsed = _parse_sandbox(euid)
        payload = f"{parsed.sandbox}{parsed.category}{parsed.body}"
    else:
        parsed = _parse_production(euid)
        payload = f"{parsed.category}{parsed.body}"

    expected = mod32_check_character(payload)
    if parsed.checksum != expected:
        raise EUIDChecksumError("checksum mismatch")

    return {
        "sandbox": parsed.sandbox,
        "category": parsed.category,
        "body": parsed.body,
        "checksum": parsed.checksum,
        "integer": _base32_to_int(parsed.body),
    }
