"""Определение кодировки текстовых файлов корпуса (P1-1, P1-3).

Зачем модуль. До него ВСЕ тексты читались как `utf-8, errors="replace"` —
и это было верно ровно для UTF-8 и ASCII. Корпус BWO (и вообще любая
разработка на PHP/Perl родом из 2000-х) держит часть файлов в CP1251:
`upload.php`, `mysql_stdin.pl`, `import*.pl`. При чтении с `replace`
кириллица превращается в `U+FFFD`, и:

  * поиск по русской строке («Разрешённые форматы») не находит НИЧЕГО,
    хотя строка в файле есть;
  * русские комментарии и строки не попадают в лексику и в текст чанка;
  * разбор деревом видит вместо идентификаторов ромбы, а строковые
    литералы теряют содержимое.

Молчаливая потеря целого класса файлов — это не «мелкое неудобство»,
а именно тот случай, когда операция формально работает и отвечает
«не найдено» на существующее.

Правила определения детерминированы (НФТ-5): результат зависит только от
байтов файла, не от настроек машины и порядка вызовов.

    1. BOM UTF-8 / UTF-16 — кодировка названа самим файлом;
    2. строгий разбор UTF-8 проходит — `utf-8`;
    3. иначе CP1251 и в тексте есть кириллица — `cp1251`;
    4. иначе `cp1252` (однобайтовая западноевропейская легенда).

Шаг 3 не может быть «просто CP1251 по умолчанию»: CP1251 успешно
декодирует почти любую последовательность байт, поэтому без проверки
на кириллицу UTF-16LE-текст или битая латиница объявлялись бы русским
текстом, и признак кодировки был бы бесполезен.
"""

from __future__ import annotations

import codecs

#: Кодировка, которая считается «обычной» и не требует перекодирования.
NATIVE = "utf-8"

#: Доля кириллицы среди букв, при которой однобайтовый текст признаётся CP1251.
#: Порог низкий осознанно: в файле достаточно пары русских строк из шапки,
#: чтобы это был именно CP1251. Слишком высокий порог вернул бы нас к
#: прежнему поведению — «декодировали как UTF-8 и потеряли файл».
CYRILLIC_SHARE = 0.05

#: Сколько кириллических букв нужно увидеть, чтобы объявить текст русским.
#: Одной доли мало: в латинском тексте «café naïve» байты 0xE9 и 0xEF лежат
#: в кириллическом диапазоне CP1251, и по доле такой файл объявлялся бы
#: русским — то есть ответ врал бы о кодировке. Четыре буквы — минимум,
#: при котором совпадение случайно быть уже не может.
MIN_CYRILLIC_LETTERS = 4

#: ASCII-буквы: A-Z и a-z. Используются для доли кириллицы среди БУКВ.
_ASCII_LETTERS = set(range(0x41, 0x5B)) | set(range(0x61, 0x7B))
#: Кириллические буквы CP1251: А-я (0xC0-0xFF), Ё (0xA8), ё (0xB8).
#: Именно эти байты и означают «текст русский»; остальные байты старшей
#: половины CP1251 — знаки препинания и типографика, и по ним решать нельзя.
_CYR_BYTES = set(range(0xC0, 0x100)) | {0xA8, 0xB8}
_ALL_BYTES = set(range(256))
_NON_LETTER_BYTES = bytes(sorted(_ALL_BYTES - _ASCII_LETTERS - _CYR_BYTES))
_NON_CYR_BYTES = bytes(sorted(_ALL_BYTES - _CYR_BYTES))
#: Сколько байт смотрим, прежде чем решить. Разбор идёт блоками: файл,
#: начинающийся с разметки, обязан всё равно быть опознан по своему тексту.
_SAMPLE = 65536


def _cyrillic_share(data: bytes) -> tuple[int, int]:
    """(букв, из них кириллических) по байтам CP1251.

    Подсчёт идёт `bytes.translate` — на уровне C, а не циклом Python:
    определение кодировки вызывается для КАЖДОГО файла корпуса, и цикл
    по символам на 1000 файлов стоил бы секунд. Проверено: на ASCII-плотном
    файле в 1 МБ это 1,4 мс против 90 мс у построчного перебора.
    """
    letters = 0
    cyrillic = 0
    pos = 0
    while pos < len(data):
        block = data[pos : pos + _SAMPLE]
        pos += _SAMPLE
        letters += len(block.translate(None, _NON_LETTER_BYTES))
        cyrillic += len(block.translate(None, _NON_CYR_BYTES))
        # Как только буквы найдены и решение устойчиво — дальше не читаем.
        if letters and cyrillic / letters >= CYRILLIC_SHARE:
            break
    return letters, cyrillic


def detect(data: bytes) -> str:
    """Назвать кодировку файла по его байтам.

    Возвращает имя кодека Python. Для UTF-16 BOM сохраняется как `utf-16`
    (декодер сам разберёт порядок байт), для UTF-8 BOM — `utf-8-sig`.
    """
    if data.startswith(codecs.BOM_UTF8):
        return "utf-8-sig"
    if data.startswith(codecs.BOM_UTF16_LE) or data.startswith(codecs.BOM_UTF16_BE):
        return "utf-16"
    try:
        data.decode("utf-8")
        return "utf-8"
    except UnicodeDecodeError:
        pass
    letters, cyrillic = _cyrillic_share(data)
    if (
        cyrillic >= MIN_CYRILLIC_LETTERS
        and letters
        and cyrillic / letters >= CYRILLIC_SHARE
    ):
        return "cp1251"
    return "cp1252"


def decode(data: bytes, path: str | None = None) -> tuple[str, str]:
    """Разобрать байты в текст. Возвращает (текст, имя кодировки).

    Аргумент `path` в определении не участвует — он существует только для
    читаемости места вызова и для будущего журнала: правило обязано быть
    свойством СОДЕРЖИМОГО, а не расширения файла, иначе файл `.php`
    с изменённой кодировкой снова читался бы неверно.
    """
    enc = detect(data)
    if enc == "utf-8-sig":
        text = data.decode("utf-8-sig", errors="replace")
    else:
        text = data.decode(enc, errors="replace")
    return text, enc


def transcode(data: bytes, path: str | None = None) -> tuple[bytes, str]:
    """Привести байты к UTF-8. Возвращает (байты_utf8, исходная_кодировка).

    Именно это нужно разбору деревом: грамматика ожидает UTF-8, а смещение
    узлов считается в байтах ИМЕННО того буфера, который ей передан.
    Поэтому перекодирование делается один раз до разбора, и тот же буфер
    используется для нарезки чанков — иначе строки чанка разошлись бы
    со строками дерева.
    """
    text, enc = decode(data, path)
    if enc == NATIVE:
        return data, enc
    return text.encode("utf-8"), enc


def prepare(data: bytes, path: str | None = None) -> tuple[str, str, bytes]:
    """Текст, кодировка и UTF-8-байты за один проход (для извлекателей).

    Три значения вместе, потому что извлекателю нужны все три сразу:
    текст — для нарезки чанков, байты — для разбора деревом (смещения узлов),
    имя кодировки — для отчёта и для колонки `file.encoding`.
    """
    text, enc = decode(data, path)
    return text, enc, (data if enc == NATIVE else text.encode("utf-8"))


def line_count(raw: bytes, text: str, enc: str) -> int:
    """Число строк файла.

    Для однобайтовых кодировок (UTF-8, CP1251) перевод строки — байт 0x0A,
    и подсчёт по байтам остаётся прежним, дешёвым. Для UTF-16 байт 0x0A
    разделён нулём, поэтому считать надо по разобранному тексту: иначе
    у каждого такого файла было бы «1 строка».
    """
    if enc == "utf-16":
        return max(1, len(text.splitlines()))
    return raw.count(b"\n") + 1


def known(enc: str) -> bool:
    """Известна ли кодировка этому модулю (нужно для отчётов поиска)."""
    return enc in {"utf-8", "utf-8-sig", "utf-16", "cp1251", "cp1252"}
