"""Conversion between integers and roman numerals.

Copyright (c) 2024, Adam Turner.

This project is licenced under the terms of either the Zero-Clause BSD licence
or the CC0 1.0 Universal licence.
"""

from __future__ import annotations

import sys

TYPE_CHECKING = False
if TYPE_CHECKING:
    from typing import Final, TypeVar, final

    from typing_extensions import Self

    _T = TypeVar('_T')
else:

    def final(f: _T) -> _T:
        return f


__version__: Final = '3.1.0'
version_info: Final = (3, 1, 0)

__all__: Final = (
    'MAX',
    'MIN',
    'InvalidRomanNumeralError',
    'OutOfRangeError',
    'RomanNumeral',
)

MIN: Final = 1
"""The value of the smallest well-formed roman numeral."""

MAX: Final = 3_999
"""The value of the largest well-formed roman numeral."""


@final
class OutOfRangeError(TypeError):
    """Number out of range (must be between 1 and 3,999)."""


@final
class InvalidRomanNumeralError(ValueError):
    """Not a valid Roman numeral."""

    def __init__(self, value: str, *args: object) -> None:
        msg = f'Invalid Roman numeral: {value}'
        super().__init__(msg, *args)


@final
class RomanNumeral:
    """A Roman numeral.

    Only values between 1 and 3,999 are valid.
    Stores the value internally as an ``int``.

    >>> answer = RomanNumeral(42)
    >>> print(answer.to_uppercase())
    XLII
    """

    __slots__ = ('_value',)
    _value: int

    def __init__(self, value: int, /) -> None:
        if not isinstance(value, int):  # pyright: ignore[reportUnnecessaryIsInstance]
            value_qualname = type(value).__qualname__
            msg = f'RomanNumeral: an integer is required, not {value_qualname!r}'
            raise TypeError(msg)
        if value < MIN or value > MAX:
            msg = f'Number out of range (must be between 1 and 3,999). Got {value}.'
            raise OutOfRangeError(msg)
        super().__setattr__('_value', value)

    def __int__(self) -> int:
        """Return the integer value of this numeral."""
        return self._value

    def __str__(self) -> str:
        """Return the well-formed (uppercase) string for this numeral."""
        return self.to_uppercase()

    def __repr__(self) -> str:
        """Return the string representation of this numeral."""
        return f'{self.__class__.__name__}({self._value!r})'

    def __eq__(self, other: object) -> bool:
        """Return self == other."""
        if isinstance(other, RomanNumeral):
            return self._value == other._value
        return NotImplemented

    def __lt__(self, other: object) -> bool:
        """Return self < other."""
        if isinstance(other, RomanNumeral):
            return self._value < other._value
        return NotImplemented

    def __hash__(self) -> int:
        """Return the hashed value of this numeral."""
        return hash(self._value)

    def __setattr__(self, key: str, value: object) -> None:
        """Implement setattr(self, name, value)."""
        if key == '_value':
            msg = f'Cannot set the {key!r} attribute.'
            raise AttributeError(msg)
        super().__setattr__(key, value)

    def to_uppercase(self) -> str:
        """Convert a ``RomanNumeral`` to an uppercase string.

        >>> answer = RomanNumeral(42)
        >>> assert answer.to_uppercase() == 'XLII'
        """
        out: list[str] = []
        n = self._value
        for value, name, _ in _ROMAN_NUMERAL_PREFIXES:
            while n >= value:
                n -= value
                out.append(name)
        return ''.join(out)

    def to_lowercase(self) -> str:
        """Convert a ``RomanNumeral`` to a lowercase string.

        >>> answer = RomanNumeral(42)
        >>> assert answer.to_lowercase() == 'xlii'
        """
        out: list[str] = []
        n = self._value
        for value, _, name in _ROMAN_NUMERAL_PREFIXES:
            while n >= value:
                n -= value
                out.append(name)
        return ''.join(out)

    @classmethod
    def from_string(cls, string: str, /) -> Self:  # NoQA: C901, PLR0912, PLR0915
        """Create a ``RomanNumeral`` from a well-formed string representation.

        Returns ``RomanNumeral`` or raises ``InvalidRomanNumeralError``.

        >>> answer = RomanNumeral.from_string('XLII')
        >>> assert int(answer) == 42
        """
        # Not an empty string.
        if not string or not isinstance(string, str):  # pyright: ignore[reportUnnecessaryIsInstance]
            raise InvalidRomanNumeralError(string)

        # ASCII-only uppercase string.
        if string.isascii() and string.isupper():
            chars = string.encode('ascii')
        elif string.isascii() and string.islower():
            chars = string.upper().encode('ascii')
        else:
            # Either Non-ASCII or mixed-case ASCII.
            raise InvalidRomanNumeralError(string)

        # ASCII-only uppercase string only containing I, V, X, L, C, D, M.
        if not frozenset(b'IVXLCDM').issuperset(chars):
            raise InvalidRomanNumeralError(string)

        result: int = 0
        idx: int = 0

        # Thousands: between 0 and 4 "M" characters at the start
        for _ in range(4):
            if chars[idx : idx + 1] == b'M':
                result += 1000
                idx += 1
            else:
                break
        if len(chars) == idx:
            return cls(result)

        # Hundreds: 900 ("CM"), 400 ("CD"), 0-300 (0 to 3 "C" chars),
        # or 500-800 ("D", followed by 0 to 3 "C" chars)
        if chars[idx : idx + 2] == b'CM':
            result += 900
            idx += 2
        elif chars[idx : idx + 2] == b'CD':
            result += 400
            idx += 2
        else:
            if chars[idx : idx + 1] == b'D':
                result += 500
                idx += 1
            for _ in range(3):
                if chars[idx : idx + 1] == b'C':
                    result += 100
                    idx += 1
                else:
                    break
        if len(chars) == idx:
            return cls(result)

        # Tens: 90 ("XC"), 40 ("XL"), 0-30 (0 to 3 "X" chars),
        # or 50-80 ("L", followed by 0 to 3 "X" chars)
        if chars[idx : idx + 2] == b'XC':
            result += 90
            idx += 2
        elif chars[idx : idx + 2] == b'XL':
            result += 40
            idx += 2
        else:
            if chars[idx : idx + 1] == b'L':
                result += 50
                idx += 1
            for _ in range(3):
                if chars[idx : idx + 1] == b'X':
                    result += 10
                    idx += 1
                else:
                    break
        if len(chars) == idx:
            return cls(result)

        # Ones: 9 ("IX"), 4 ("IV"), 0-3 (0 to 3 "I" chars),
        # or 5-8 ("V", followed by 0 to 3 "I" chars)
        if chars[idx : idx + 2] == b'IX':
            result += 9
            idx += 2
        elif chars[idx : idx + 2] == b'IV':
            result += 4
            idx += 2
        else:
            if chars[idx : idx + 1] == b'V':
                result += 5
                idx += 1
            for _ in range(3):
                if chars[idx : idx + 1] == b'I':
                    result += 1
                    idx += 1
                else:
                    break
        if len(chars) == idx:
            return cls(result)
        raise InvalidRomanNumeralError(string)


_ROMAN_NUMERAL_PREFIXES: Final = [
    (1000, sys.intern('M'), sys.intern('m')),
    (900, sys.intern('CM'), sys.intern('cm')),
    (500, sys.intern('D'), sys.intern('d')),
    (400, sys.intern('CD'), sys.intern('cd')),
    (100, sys.intern('C'), sys.intern('c')),
    (90, sys.intern('XC'), sys.intern('xc')),
    (50, sys.intern('L'), sys.intern('l')),
    (40, sys.intern('XL'), sys.intern('xl')),
    (10, sys.intern('X'), sys.intern('x')),
    (9, sys.intern('IX'), sys.intern('ix')),
    (5, sys.intern('V'), sys.intern('v')),
    (4, sys.intern('IV'), sys.intern('iv')),
    (1, sys.intern('I'), sys.intern('i')),
]
"""Numeral value, uppercase character, and lowercase character."""
