"""Human-enterable serial parsing and typo detection.

Serial generation and keyed storage remain server-only. This module contains
only the public format/checksum contract needed by both sides.
"""

from __future__ import annotations

from dataclasses import dataclass
import hashlib


SERIAL_PRODUCT_PREFIX = "MSA1"
SERIAL_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
SERIAL_LOOKUP_CHARACTERS = 6
SERIAL_SECRET_CHARACTERS = 26
SERIAL_CHECKSUM_CHARACTERS = 4
SERIAL_GROUP_CHARACTERS = 4
SERIAL_CHECKSUM_PERSON = b"APLNSER1"
SERIAL_BODY_CHARACTERS = (
    SERIAL_LOOKUP_CHARACTERS + SERIAL_SECRET_CHARACTERS + SERIAL_CHECKSUM_CHARACTERS
)
SERIAL_COMPACT_CHARACTERS = len(SERIAL_PRODUCT_PREFIX) + SERIAL_BODY_CHARACTERS


def _base32_from_bytes(value: bytes, character_count: int) -> str:
    if not isinstance(value, bytes):
        raise TypeError("value must be bytes")
    if isinstance(character_count, bool) or not isinstance(character_count, int):
        raise TypeError("character_count must be an integer")
    if character_count < 1:
        raise ValueError("character_count must be at least one")
    bits = int.from_bytes(value, "big")
    total_bits = len(value) * 8
    required_bits = character_count * 5
    if total_bits < required_bits:
        bits <<= required_bits - total_bits
    elif total_bits > required_bits:
        bits >>= total_bits - required_bits
    characters = []
    for shift in range(required_bits - 5, -1, -5):
        characters.append(SERIAL_ALPHABET[(bits >> shift) & 0x1F])
    return "".join(characters)


def serial_checksum(prefix: str, lookup_id: str, secret: str) -> str:
    if not isinstance(prefix, str):
        raise TypeError("prefix must be a string")
    if not isinstance(lookup_id, str):
        raise TypeError("lookup_id must be a string")
    if not isinstance(secret, str):
        raise TypeError("secret must be a string")
    normalized = (prefix + lookup_id + secret).upper().encode("ascii")
    digest = hashlib.blake2s(
        normalized,
        digest_size=3,
        person=SERIAL_CHECKSUM_PERSON,
    ).digest()
    return _base32_from_bytes(digest, SERIAL_CHECKSUM_CHARACTERS)


def _format_compact(compact: str) -> str:
    prefix = compact[: len(SERIAL_PRODUCT_PREFIX)]
    body = compact[len(SERIAL_PRODUCT_PREFIX) :]
    groups = [
        body[index : index + SERIAL_GROUP_CHARACTERS]
        for index in range(0, len(body), SERIAL_GROUP_CHARACTERS)
    ]
    return "-".join((prefix, *groups))


@dataclass(frozen=True)
class ParsedSerial:
    prefix: str
    lookup_id: str
    secret: str
    checksum: str

    def __post_init__(self) -> None:
        for field_name in ("prefix", "lookup_id", "secret", "checksum"):
            value = getattr(self, field_name)
            if not isinstance(value, str):
                raise TypeError(f"{field_name} must be a string")
        if self.prefix != SERIAL_PRODUCT_PREFIX:
            raise ValueError("serial uses an unsupported product prefix")
        if len(self.lookup_id) != SERIAL_LOOKUP_CHARACTERS:
            raise ValueError("serial lookup ID has the wrong length")
        if len(self.secret) != SERIAL_SECRET_CHARACTERS:
            raise ValueError("serial secret has the wrong length")
        if len(self.checksum) != SERIAL_CHECKSUM_CHARACTERS:
            raise ValueError("serial checksum has the wrong length")
        for value in (self.lookup_id, self.secret, self.checksum):
            if any(character not in SERIAL_ALPHABET for character in value):
                raise ValueError("serial contains an unsupported character")
        expected = serial_checksum(self.prefix, self.lookup_id, self.secret)
        if self.checksum != expected:
            raise ValueError("serial checksum does not match")

    @property
    def compact(self) -> str:
        return self.prefix + self.lookup_id + self.secret + self.checksum

    @property
    def formatted(self) -> str:
        return _format_compact(self.compact)


def parse_serial(value: str) -> ParsedSerial:
    if not isinstance(value, str):
        raise TypeError("value must be a string")
    compact = "".join(character for character in value.upper() if not character.isspace() and character != "-")
    if len(compact) != SERIAL_COMPACT_CHARACTERS:
        raise ValueError("serial has the wrong length")
    prefix_end = len(SERIAL_PRODUCT_PREFIX)
    lookup_end = prefix_end + SERIAL_LOOKUP_CHARACTERS
    secret_end = lookup_end + SERIAL_SECRET_CHARACTERS
    return ParsedSerial(
        prefix=compact[:prefix_end],
        lookup_id=compact[prefix_end:lookup_end],
        secret=compact[lookup_end:secret_end],
        checksum=compact[secret_end:],
    )


def normalize_serial(value: str) -> str:
    return parse_serial(value).formatted


def redact_serial(value: str) -> str:
    parsed = parse_serial(value)
    return f"{parsed.prefix}-****-****-{parsed.secret[-4:]}"


__all__ = [
    "ParsedSerial",
    "SERIAL_ALPHABET",
    "SERIAL_CHECKSUM_CHARACTERS",
    "SERIAL_LOOKUP_CHARACTERS",
    "SERIAL_PRODUCT_PREFIX",
    "SERIAL_SECRET_CHARACTERS",
    "normalize_serial",
    "parse_serial",
    "redact_serial",
    "serial_checksum",
]
