"""
GS1 Digital Link — офлайн-парсер и валидатор.

Тот же модуль, которым разбирает ссылки резолвер rsl.by. Работает полностью локально,
без единого сетевого запроса; зависимостей нет, только стандартная библиотека Python 3.10+.

    >>> import gs1_digital_link as dl
    >>> link = dl.parse('/01/04660043856680/21/2Pfgxu6b', {'93': '7SQ4'})
    >>> link.primary_value, link.qualifiers, link.attributes
    ('04660043856680', (('21', '2Pfgxu6b'),), {'93': '7SQ4'})
    >>> link.element_string
    '(01)04660043856680(21)2Pfgxu6b(93)7SQ4'

Невалидный путь поднимает DigitalLinkError; try_parse() вместо исключения возвращает
пару (None, причина).

Грамматика (GS1 Digital Link URI Syntax 1.7.0):

    /<первичный ключ>/<значение>[/<квалификатор>/<значение>]*[?<атрибут>=<значение>&...]

Первым сегментом пути может стоять только первичный ключ, за ним — только разрешённые
для него квалификаторы и только в заданном порядке. Всё остальное (дата, вес, крипто-хвост
и прочее) — это атрибуты, и их место в query-строке, а не в пути.
"""

import re
from typing import Any, NamedTuple

# --- Первичные ключи: AI -> человекочитаемое имя ---------------------------------------
PRIMARY_KEYS: dict[str, str] = {
    '00': 'SSCC',
    '01': 'GTIN',
    '253': 'GDTI',
    '255': 'GCN',
    '401': 'GINC',
    '402': 'GSIN',
    '414': 'GLN (физическая локация)',
    '415': 'GLN (плательщик)',
    '417': 'GLN (участник)',
    '8003': 'GRAI',
    '8004': 'GIAI',
    '8006': 'ITIP',
    '8010': 'CPID',
    '8013': 'GMN',
    '8017': 'GSRN (поставщик услуги)',
    '8018': 'GSRN (получатель услуги)',
}

# --- Квалификаторы, разрешённые для каждого первичного ключа, в каноническом порядке ----
# Порядок значим: /01/<gtin>/10/<партия>/21/<серийник> валидно, обратный порядок — нет.
KEY_QUALIFIERS: dict[str, tuple[str, ...]] = {
    '01': ('22', '10', '21'),
    '8006': ('22', '10', '21'),
    '414': ('254',),
    '415': ('8020',),
    '8010': ('8011',),
    '8017': ('8019',),
    '8018': ('8019',),
}

# --- Форматы значений ------------------------------------------------------------------
# Набор символов GS1 AI 82 в том виде, в каком он может встретиться в пути URI.
_AI82 = r"[!%-?A-Z_a-z\x22]"

# (регулярка, нужна ли проверка контрольной цифры, длина проверяемой части)
_NUMERIC = r'\d'


class _Format(NamedTuple):
    pattern: re.Pattern[str]
    check_digit_length: int | None  # длина числовой части с контрольной цифрой в конце


def _fmt(pattern: str, check_digit_length: int | None = None) -> _Format:
    return _Format(re.compile('^' + pattern + '$'), check_digit_length)


AI_FORMATS: dict[str, _Format] = {
    # первичные ключи
    '00': _fmt(_NUMERIC + '{18}', 18),
    '01': _fmt(_NUMERIC + '{14}', 14),           # 8/12/13 нормализуются до 14 до проверки
    '253': _fmt(_NUMERIC + '{13}' + _AI82 + '{0,17}', 13),
    '255': _fmt(_NUMERIC + '{13}' + _NUMERIC + '{0,12}', 13),
    '401': _fmt(_AI82 + '{1,30}'),
    '402': _fmt(_NUMERIC + '{17}', 17),
    '414': _fmt(_NUMERIC + '{13}', 13),
    '415': _fmt(_NUMERIC + '{13}', 13),
    '417': _fmt(_NUMERIC + '{13}', 13),
    '8003': _fmt('0' + _NUMERIC + '{13}' + _AI82 + '{0,16}', 14),
    '8004': _fmt(_AI82 + '{1,30}'),
    '8006': _fmt(_NUMERIC + '{18}', 14),         # 14 GTIN + 2 номер части + 2 всего частей
    '8010': _fmt(r'[#\-/0-9A-Z]{1,30}'),
    '8013': _fmt(_AI82 + '{1,25}'),
    '8017': _fmt(_NUMERIC + '{18}', 18),
    '8018': _fmt(_NUMERIC + '{18}', 18),
    # квалификаторы
    '10': _fmt(_AI82 + '{1,20}'),
    '21': _fmt(_AI82 + '{1,20}'),
    '22': _fmt(_AI82 + '{1,20}'),
    '254': _fmt(_AI82 + '{1,20}'),
    '8011': _fmt(_NUMERIC + '{1,12}'),
    '8019': _fmt(_NUMERIC + '{1,10}'),
    '8020': _fmt(_AI82 + '{1,25}'),
}

# GTIN короче 14 знаков дополняется нулями слева: один и тот же товар,
# отсканированный с EAN-13 и с ITF-14, обязан найти одну и ту же запись.
_SHORT_GTIN_LENGTHS = (8, 12, 13)


class DigitalLinkError(ValueError):
    """Синтаксис не соответствует грамматике GS1 Digital Link."""


class DigitalLink(NamedTuple):
    """Разобранный GS1 Digital Link."""

    primary_key: str                  # AI первичного ключа, например '01'
    primary_value: str                # значение, GTIN уже нормализован до 14 знаков
    qualifiers: tuple[tuple[str, str], ...]   # ((AI, значение), ...) в порядке из пути
    attributes: dict[str, str]        # параметры query, которые являются AI (17, 93 и т.п.)
    query: dict[str, str]             # вся query-строка целиком, включая linkType и прочее

    @property
    def identifier_path(self) -> str:
        """Путь первичного ключа: /01/04607177431239"""
        return f'/{self.primary_key}/{self.primary_value}'

    @property
    def qualifier_path(self) -> str:
        """Путь квалификаторов: /10/L2408A/21/SN001 (пустая строка, если их нет)"""
        return ''.join(f'/{ai}/{value}' for ai, value in self.qualifiers)

    @property
    def path(self) -> str:
        return self.identifier_path + self.qualifier_path

    @property
    def element_string(self) -> str:
        """Классическая запись через круглые скобки: (01)04607177431239(10)L2408A"""
        parts = [f'({self.primary_key}){self.primary_value}']
        parts += [f'({ai}){value}' for ai, value in self.qualifiers]
        parts += [f'({ai}){value}' for ai, value in sorted(self.attributes.items())]
        return ''.join(parts)


def check_digit(digits: str) -> str:
    """
    Стандартная контрольная цифра GS1 (mod 10) для числовой строки без неё.
    Веса 3 и 1 чередуются справа налево.
    """
    total = 0
    for position, char in enumerate(reversed(digits)):
        total += int(char) * (3 if position % 2 == 0 else 1)
    return str((10 - total % 10) % 10)


def has_valid_check_digit(value: str) -> bool:
    return len(value) > 1 and value[:-1].isdigit() and check_digit(value[:-1]) == value[-1]


def normalise_gtin(value: str) -> str:
    """GTIN-8/12/13 дополняется нулями слева до GTIN-14."""
    if value.isdigit() and len(value) in _SHORT_GTIN_LENGTHS:
        return value.rjust(14, '0')
    return value


def validate_ai_value(ai: str, value: str) -> None:
    """Проверяет значение AI по формату и, где положено, по контрольной цифре."""
    fmt = AI_FORMATS.get(ai)
    if fmt is None:
        raise DigitalLinkError(f'AI {ai} не поддерживается в пути')

    if not fmt.pattern.match(value):
        raise DigitalLinkError(f'значение AI {ai} не соответствует формату: {value!r}')

    if fmt.check_digit_length is not None:
        checked_part = value[:fmt.check_digit_length]
        if not has_valid_check_digit(checked_part):
            raise DigitalLinkError(f'неверная контрольная цифра в AI {ai}: {value!r}')


def is_attribute_ai(name: str) -> bool:
    """
    Является ли имя параметра query идентификатором применения. Такие параметры (17 — годен до,
    93 — внутренние данные компании и т.п.) переносятся в целевой URL как данные,
    остальные (linkType, context) — управляют самим резолвом.
    """
    return name.isdigit() and 2 <= len(name) <= 4


def parse(path: str, query: dict[str, str] | None = None) -> DigitalLink:
    """
    Разбирает путь GS1 Digital Link.

    :param path: путь запроса, например '/01/04607177431239/10/L2408A'
    :param query: параметры query-строки
    :raises DigitalLinkError: если путь не является валидным GS1 Digital Link
    """
    query = dict(query or {})
    segments = [segment for segment in path.strip('/').split('/') if segment != '']

    if len(segments) < 2:
        raise DigitalLinkError('в пути нет пары «ключ/значение»')

    if len(segments) % 2 != 0:
        raise DigitalLinkError('в пути нечётное число сегментов')

    primary_key, primary_value = segments[0], segments[1]

    if primary_key not in PRIMARY_KEYS:
        # Именно сюда попадают попытки поставить в начало пути атрибут - например /02/<gtin>,
        # где 02 описывает содержимое, а не идентифицирует объект.
        raise DigitalLinkError(f'AI {primary_key} не является первичным ключом')

    if primary_key == '01':
        primary_value = normalise_gtin(primary_value)

    validate_ai_value(primary_key, primary_value)

    # --- квалификаторы -----------------------------------------------------------------
    allowed = KEY_QUALIFIERS.get(primary_key, ())
    qualifiers: list[tuple[str, str]] = []
    next_allowed_index = 0

    for index in range(2, len(segments), 2):
        ai, value = segments[index], segments[index + 1]

        if ai not in allowed:
            raise DigitalLinkError(
                f'AI {ai} не является квалификатором для {primary_key} '
                f'({PRIMARY_KEYS[primary_key]})')

        position = allowed.index(ai)
        if position < next_allowed_index:
            raise DigitalLinkError(
                f'квалификаторы идут не в том порядке: для {primary_key} ожидается '
                f'{"/".join(allowed)}')

        validate_ai_value(ai, value)
        qualifiers.append((ai, value))
        next_allowed_index = position + 1

    attributes = {name: value for name, value in query.items() if is_attribute_ai(name)}

    return DigitalLink(
        primary_key=primary_key,
        primary_value=primary_value,
        qualifiers=tuple(qualifiers),
        attributes=attributes,
        query=query,
    )


def try_parse(path: str, query: dict[str, str] | None = None) -> tuple[DigitalLink | None, str]:
    """Как parse(), но вместо исключения возвращает (None, текст ошибки)."""
    try:
        return parse(path, query), ''
    except DigitalLinkError as error:
        return None, str(error)


def qualifier_prefixes(link: DigitalLink) -> list[str]:
    """
    Пути от самого точного к самому общему — для отката вверх по иерархии.

    Для /01/<gtin>/10/L2408A/21/SN001 вернёт:
        ['/10/L2408A/21/SN001', '/10/L2408A', '']

    Резолвер идёт по этому списку и отдаёт первое, на что заведены ссылки: нашлась запись
    под конкретный серийник - отдаём её, нет - поднимаемся к партии, потом к самому GTIN.
    """
    prefixes = []
    for length in range(len(link.qualifiers), 0, -1):
        prefixes.append(''.join(f'/{ai}/{value}' for ai, value in link.qualifiers[:length]))
    prefixes.append('')
    return prefixes


def describe(link: DigitalLink) -> dict[str, Any]:
    """Человекочитаемый разбор — для отладочной ручки и для страницы проверки кода."""
    return {
        'primaryKey': {'ai': link.primary_key,
                       'name': PRIMARY_KEYS[link.primary_key],
                       'value': link.primary_value},
        'qualifiers': [{'ai': ai, 'value': value} for ai, value in link.qualifiers],
        'attributes': link.attributes,
        'elementString': link.element_string,
        'canonicalPath': link.path,
    }
