# Copyright (C) 2026 NPO NT
# SPDX-License-Identifier: AGPL-3.0-only

from __future__ import annotations

from pathlib import Path

from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
    BaseDocTemplate,
    Flowable,
    Frame,
    KeepTogether,
    PageBreak,
    PageTemplate,
    Paragraph,
    Spacer,
    Table,
    TableStyle,
)


INK = colors.HexColor("#153126")
GREEN = colors.HexColor("#244c3b")
LIME = colors.HexColor("#b8e65b")
PAPER = colors.HexColor("#f5f1e8")
MUTED = colors.HexColor("#63716a")
LINE = colors.HexColor("#d8ded8")
RED = colors.HexColor("#b33a3a")


class StatusCard(Flowable):
    def __init__(self, width: float, title: str, body: str, accent: colors.Color = LIME) -> None:
        super().__init__()
        self.width = width
        self.height = 26 * mm
        self.title = title
        self.body = body
        self.accent = accent

    def draw(self) -> None:
        canvas = self.canv
        canvas.setFillColor(colors.white)
        canvas.setStrokeColor(LINE)
        canvas.roundRect(0, 0, self.width, self.height, 4 * mm, fill=1, stroke=1)
        canvas.setFillColor(self.accent)
        canvas.roundRect(4 * mm, self.height - 10 * mm, 42 * mm, 6 * mm, 3 * mm, fill=1, stroke=0)
        canvas.setFillColor(INK)
        canvas.setFont("DossierBold", 8)
        canvas.drawCentredString(25 * mm, self.height - 8 * mm, self.title)
        canvas.setFont("Dossier", 10)
        canvas.drawString(5 * mm, 6 * mm, self.body)


def register_fonts() -> None:
    regular = Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf")
    bold = Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf")
    if not regular.exists():
        regular = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
    if not bold.exists():
        bold = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf")
    pdfmetrics.registerFont(TTFont("Dossier", str(regular)))
    pdfmetrics.registerFont(TTFont("DossierBold", str(bold if bold.exists() else regular)))


def page_decor(canvas, document) -> None:
    canvas.saveState()
    width, height = A4
    canvas.setFillColor(PAPER)
    canvas.rect(0, 0, width, height, fill=1, stroke=0)
    canvas.setStrokeColor(LINE)
    canvas.line(20 * mm, 15 * mm, width - 20 * mm, 15 * mm)
    canvas.setFont("Dossier", 8)
    canvas.setFillColor(MUTED)
    canvas.drawString(20 * mm, 9.5 * mm, "MIKA De-ID Local 0.2.1 | NPO NT | техническое досье | 27.08.2026")
    canvas.drawRightString(width - 20 * mm, 9.5 * mm, f"{document.page}")
    canvas.restoreState()


def styles() -> dict[str, ParagraphStyle]:
    base = getSampleStyleSheet()
    return {
        "title": ParagraphStyle(
            "Title",
            parent=base["Title"],
            fontName="DossierBold",
            fontSize=29,
            leading=33,
            textColor=colors.white,
            alignment=TA_LEFT,
            spaceAfter=8 * mm,
        ),
        "subtitle": ParagraphStyle(
            "Subtitle",
            parent=base["BodyText"],
            fontName="Dossier",
            fontSize=13,
            leading=18,
            textColor=colors.white,
        ),
        "h1": ParagraphStyle(
            "H1",
            parent=base["Heading1"],
            fontName="DossierBold",
            fontSize=20,
            leading=24,
            textColor=INK,
            spaceBefore=5 * mm,
            spaceAfter=4 * mm,
        ),
        "h2": ParagraphStyle(
            "H2",
            parent=base["Heading2"],
            fontName="DossierBold",
            fontSize=13,
            leading=16,
            textColor=GREEN,
            spaceBefore=4 * mm,
            spaceAfter=2 * mm,
        ),
        "body": ParagraphStyle(
            "Body",
            parent=base["BodyText"],
            fontName="Dossier",
            fontSize=10,
            leading=14.5,
            textColor=INK,
            spaceAfter=2.5 * mm,
        ),
        "small": ParagraphStyle(
            "Small",
            parent=base["BodyText"],
            fontName="Dossier",
            fontSize=8.2,
            leading=11,
            textColor=MUTED,
        ),
        "bullet": ParagraphStyle(
            "Bullet",
            parent=base["BodyText"],
            fontName="Dossier",
            fontSize=9.5,
            leading=13.5,
            textColor=INK,
            leftIndent=5 * mm,
            firstLineIndent=-3.2 * mm,
            spaceAfter=1.4 * mm,
        ),
        "table": ParagraphStyle(
            "Table",
            parent=base["BodyText"],
            fontName="Dossier",
            fontSize=8.3,
            leading=10.5,
            textColor=INK,
        ),
        "table_head": ParagraphStyle(
            "TableHead",
            parent=base["BodyText"],
            fontName="DossierBold",
            fontSize=8.3,
            leading=10.5,
            textColor=colors.white,
        ),
        "center": ParagraphStyle(
            "Center",
            parent=base["BodyText"],
            fontName="DossierBold",
            fontSize=12,
            leading=15,
            textColor=INK,
            alignment=TA_CENTER,
        ),
    }


def p(text: str, style: ParagraphStyle) -> Paragraph:
    return Paragraph(text, style)


def bullet(text: str, style: ParagraphStyle) -> Paragraph:
    return Paragraph(f"• {text}", style)


def table(rows: list[list[str]], widths: list[float], s: dict[str, ParagraphStyle]) -> Table:
    data = [[p(cell, s["table_head"]) for cell in rows[0]]]
    data.extend([[p(cell, s["table"]) for cell in row] for row in rows[1:]])
    result = Table(data, colWidths=widths, repeatRows=1, hAlign="LEFT")
    result.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), GREEN),
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
                ("GRID", (0, 0), (-1, -1), 0.45, LINE),
                ("BACKGROUND", (0, 1), (-1, -1), colors.white),
                ("LEFTPADDING", (0, 0), (-1, -1), 6),
                ("RIGHTPADDING", (0, 0), (-1, -1), 6),
                ("TOPPADDING", (0, 0), (-1, -1), 5),
                ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
            ]
        )
    )
    return result


def build(output_path: Path) -> None:
    register_fonts()
    s = styles()
    width, height = A4
    frame = Frame(20 * mm, 19 * mm, width - 40 * mm, height - 34 * mm, id="normal")
    document = BaseDocTemplate(
        str(output_path),
        pagesize=A4,
        leftMargin=20 * mm,
        rightMargin=20 * mm,
        topMargin=15 * mm,
        bottomMargin=19 * mm,
        title="MIKA De-ID Local 0.2.1 - техническое досье",
        author="NPO NT",
        subject="AGPL open-source, synthetic-only local de-identification product",
    )
    document.addPageTemplates([PageTemplate(id="default", frames=[frame], onPage=page_decor)])

    story = []
    cover = Table(
        [[p("MIKA De-ID Local", s["title"])], [p("Локальный помощник по очистке медицинских документов перед передачей в защищённый контур МИКИ", s["subtitle"])]],
        colWidths=[width - 40 * mm],
        rowHeights=[38 * mm, 38 * mm],
    )
    cover.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, -1), INK),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("LEFTPADDING", (0, 0), (-1, -1), 10 * mm),
                ("RIGHTPADDING", (0, 0), (-1, -1), 10 * mm),
                ("TOPPADDING", (0, 0), (-1, -1), 5 * mm),
                ("BOTTOMPADDING", (0, 0), (-1, -1), 5 * mm),
            ]
        )
    )
    story.extend([Spacer(1, 28 * mm), cover, Spacer(1, 12 * mm)])
    story.append(StatusCard(width - 40 * mm, "ТЕКУЩИЙ СТАТУС", "OPEN SOURCE / LOCAL / SYNTHETIC DATA ONLY"))
    story.append(Spacer(1, 7 * mm))
    story.append(p("Версия 0.2.1 | Правообладатель: NPO NT / НПО НТ | 27 августа 2026 года", s["center"]))
    story.append(Spacer(1, 6 * mm))
    story.append(
        p(
            "Открытый продукт под GNU AGPLv3 создаёт новую растровую PDF-копию, физически удаляет подтверждённые области, не переносит скрытые объекты исходного PDF и повторно проверяет результат. Открытость относится к коду, а не к данным пациентов. Продукт не сертифицирует анонимность и пока не разрешён для реальных пациентских документов.",
            s["body"],
        )
    )
    story.append(PageBreak())

    story.append(p("1. Что уже сделано", s["h1"]))
    for text in [
        "Зафиксировано назначение, границы и политика MIKA navigation.",
        "Реализован локальный конвейер PDF, JPEG, PNG, TIFF и HEIC/HEIF.",
        "Подключён офлайн OCR для русского и английского; модели закреплены SHA-256.",
        "Подключён локальный детектор QR, Data Matrix, PDF417 и линейных штрихкодов.",
        "Реализованы правила для ФИО, рождения, телефона, email, СНИЛС, паспорта, полиса, медицинских номеров, адреса и дат.",
        "Создан интерфейс ручного просмотра: любое изменение маски снимает подтверждение страницы.",
        "Итоговый PDF пересобирается из очищенных пикселей и проходит независимую проверку.",
        "Подготовлены PRD, политика, архитектура, модель угроз, план валидации, контракт датасета и релизные гейты.",
        "NPO NT зафиксирован как правообладатель; приложение лицензировано AGPL-3.0-only.",
        "Добавлены полный текст AGPL, source offer, third-party notices, SPDX-заголовки и CycloneDX SBOM.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(p("Синтетическое доказательство", s["h2"]))
    story.append(
        table(
            [
                ["Проверка", "Результат"],
                ["Автоматические тесты", "20 из 20 пройдены"],
                ["Размеченные синтетические области", "13 из 13 найдены"],
                ["Пакетный синтетический прогон", "100 PDF / 453 страницы / 0 ошибок"],
                ["Машинная предразметка", "2 161 область; вся разметка unreviewed"],
                ["Страницы очищенной копии", "2 из 2"],
                ["Извлекаемый текст в raster-only PDF", "0 символов"],
                ["Аннотации, формы, вложения", "0"],
                ["Повторно найденные прямые идентификаторы", "0"],
                ["OCR / barcode / HEIC", "локально доступны"],
            ],
            [72 * mm, 88 * mm],
            s,
        )
    )

    story.append(PageBreak())
    story.append(p("2. Архитектура безопасности", s["h1"]))
    flow_rows = [
        ["Этап", "Защитное свойство"],
        ["Открытие", "Проверка размера, страниц, шифрования и реального типа файла"],
        ["Рендеринг", "Каждая PDF-страница переводится в RGB-пиксели"],
        ["Распознавание", "Нативный текст, OCR и штрихкоды анализируются локально"],
        ["Проверка человеком", "Все страницы подтверждаются; можно добавить ручную маску"],
        ["Пересборка", "Создаётся новый PDF без объектов исходного файла"],
        ["Повторная проверка", "Строгий парсер, повторный рендер, OCR и barcode scan"],
        ["Выпуск", "Только успешный кандидат атомарно перемещается в выбранное место"],
    ]
    story.append(table(flow_rows, [42 * mm, 118 * mm], s))
    story.append(Spacer(1, 4 * mm))
    story.append(p("Что принципиально не переносится", s["h2"]))
    for text in [
        "исходный текстовый и OCR-слой;",
        "комментарии, аннотации и формы;",
        "вложения и JavaScript;",
        "имя исходного файла и исходные метаданные;",
        "электронная подпись исходного документа.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(p("3. Политика удаления", s["h1"]))
    story.append(
        table(
            [
                ["Категория", "Действие"],
                ["ФИО, дата рождения, контакты", "Удалить"],
                ["Паспорт, СНИЛС, ИНН, полис", "Удалить"],
                ["Карта, история болезни, направление, образец", "Удалить"],
                ["QR и штрихкоды", "Удалить целиком"],
                ["Адрес", "Удалить; при необходимости оставить регион отдельно"],
                ["Диагноз, стадия, гистология, маркеры", "Сохранить"],
                ["Терапия, дозы, лабораторные значения", "Сохранить"],
                ["Точные даты и малая география", "Решение человека"],
            ],
            [70 * mm, 90 * mm],
            s,
        )
    )

    story.append(PageBreak())
    story.append(p("4. Честные ограничения", s["h1"]))
    for text in [
        "Автоматика не гарантирует обнаружение всех идентификаторов.",
        "Рукописный текст, подписи, печати, лица и плохие фотографии требуют особенно внимательной проверки.",
        "Редкое сочетание диагноза, даты, возраста и места может повторно идентифицировать человека.",
        "Загрузка из известного аккаунта сохраняет связь документа с пациентом на уровне системы.",
        "Очищенная копия остаётся чувствительной медицинской информацией и не предназначена для публичной публикации.",
        "Версия 0.2.1 не обрабатывает DICOM и не создаёт поисковый OCR-слой.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(p("5. Модель угроз и открытые риски", s["h1"]))
    story.append(
        table(
            [
                ["Риск", "Сейчас", "До пилота"],
                ["Пропуск OCR", "ручная проверка и блокировки", "репрезентативная валидация"],
                ["Вредоносный PDF", "лимиты и безопасный рендер", "ограниченный helper-процесс и fuzzing"],
                ["Утечка через лог", "нет текста в отчётах", "аудит crash/reporting"],
                ["Компрометация обновления", "обновлятора нет", "подписанные installer и manifest"],
                ["Повторная идентификация", "политика и предупреждение", "формальная оценка риска для МИКИ"],
            ],
            [42 * mm, 54 * mm, 64 * mm],
            s,
        )
    )

    story.append(PageBreak())
    story.append(p("6. Полученный синтетический корпус", s["h1"]))
    story.append(
        p(
            "Команда передала и письменно подтвердила полностью синтетический корпус. Локально обработаны 100 PDF и 453 страницы: 100 документов завершились без технической ошибки, сформирована приватная машинная предразметка из 2 161 области. Исходные значения, OCR-текст и исходные имена файлов не входят в отчёты и открытый репозиторий.",
            s["body"],
        )
    )
    story.append(p("Что уже проверено", s["h2"]))
    for text in [
        "полная пакетная загрузка и локальный OCR rus + eng;",
        "распознавание QR и штрихкодов;",
        "приватные пер-документные и агрегированные отчёты;",
        "нейтральные имена document-0001.pdf … document-0100.pdf;",
        "связь всех 100 PDF со строками синтетического реестра без экспорта значений;",
        "нормализованные JSON-координаты для последующей ручной разметки.",
    ]:
        story.append(bullet(text, s["bullet"]))
    story.append(
        p(
            "Корпус не содержит координатной эталонной разметки. Поэтому 2 161 найденная область — объём машинной предразметки, а не показатель recall или precision. Реальные документы пациентов по-прежнему не разрешены.",
            ParagraphStyle("Alert", parent=s["body"], textColor=RED, borderColor=RED, borderWidth=0.8, borderPadding=8, backColor=colors.HexColor("#fff2f0")),
        )
    )

    story.append(p("Следующий пакет от разметчиков", s["h2"]))
    story.append(
        p(
            "Проверенные annotations/document-XXXX.json с исправленными машинными областями, всеми добавленными пропусками, page_reviewed=true и human_reviewed=true. Критические категории независимо проверяются вторым разметчиком, расхождения разрешаются арбитром.",
            s["body"],
        )
    )
    story.append(
        p(
            "Исследовательская строгая сверка значений реестра нашла точное OCR-совпадение лишь для 1 из 100 ФИО и 5 из 100 дат рождения. Это не формальный miss rate: реестр не доказывает, что значение напечатано в PDF, а одна OCR-ошибка разрушает точное совпадение. Однако результат является блокирующим сигналом для разметки и проверки OCR до любого пилота.",
            s["small"],
        )
    )

    story.append(PageBreak())
    story.append(p("7. Релизные гейты", s["h1"]))
    story.append(
        table(
            [
                ["Гейт", "Назначение", "Разрешённые данные"],
                ["L1", "Локальный синтетический прототип", "Только синтетика"],
                ["D1", "Внешняя калибровка", "Синтетика или законно предварительно очищенные"],
                ["P0", "Закрытый контролируемый пилот", "Только отдельно одобренный контур"],
                ["P1", "Пациентский релиз", "После полной валидации и подписанной поставки"],
                ["I1", "Прямая загрузка в MIKA Team", "После отдельной проверки приёмного API"],
            ],
            [24 * mm, 72 * mm, 64 * mm],
            s,
        )
    )
    story.append(Spacer(1, 3 * mm))
    story.append(
        p(
            "Лицензионное решение принято: NPO NT распространяет комбинированный локальный клиент по AGPL-3.0-only. Публичный исходный код размещён на npo-nt.ru/open-source/mika-deid/. До внешнего бинарного релиза остаются обязательными точное соответствие binary/source, зарегистрированные реквизиты NPO NT и полный installer-specific SBOM.",
            ParagraphStyle("LicenceDecision", parent=s["small"], textColor=GREEN, borderColor=GREEN, borderWidth=0.6, borderPadding=6, backColor=colors.HexColor("#eff7ea")),
        )
    )
    story.append(p("Ближайшая работа после ручной разметки", s["h2"]))
    for text in [
        "проверить все 453 страницы, включая 31 страницу без машинных находок;",
        "исправить предразметку и измерить метрики отдельно по каждой категории;",
        "разделить calibration и locked-validation по пациенту/случаю;",
        "исправить причины пропусков без просмотра locked-validation при настройке;",
        "провести повторный полный синтетический и внешний прогон;",
        "подготовить решение о допуске к закрытому пилоту.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(p("8. Состав исходного комплекта", s["h1"]))
    story.append(
        table(
            [
                ["Раздел", "Содержимое"],
                ["src/mika_deid", "движок, GUI, OCR, детекторы, пересборка, верификатор"],
                ["tests", "20 автоматических safety, dataset и open-source release тестов"],
                ["scripts", "синтетический генератор, batch OCR, предразметка, registry-value evaluation, валидатор"],
                ["docs", "PRD, политика, архитектура, угрозы, валидация, датасет, AGPL governance и гейты"],
                ["licensing", "AGPLv3, source offer, third-party notices, SPDX и CycloneDX SBOM"],
                ["models/tessdata", "локальные ru/en OCR-модели и SHA-256"],
                ["examples", "синтетический источник, разметка и проверенная очищенная копия"],
            ],
            [42 * mm, 118 * mm],
            s,
        )
    )

    story.append(PageBreak())
    story.append(p("9. Источники и стандарты", s["h1"]))
    references = [
        "Федеральный закон № 152-ФЗ, статья 3: https://www.consultant.ru/document/cons_doc_LAW_61801/4f41fe599ce341751e4e34dc50a4b676674c1416/",
        "Федеральный закон № 152-ФЗ, статья 10: https://www.consultant.ru/document/cons_doc_LAW_61801/26edb2934b899bf9c74c3a8f7e574651c6565e6d/",
        "Приказ Роскомнадзора № 140 от 19.06.2025: https://publication.pravo.gov.ru/document/0001202508010002",
        "Adobe, redaction and hidden-information sanitization: https://helpx.adobe.com/acrobat/desktop/protect-documents/redact-pdfs/redacting-sanitizing.html",
        "Tesseract documentation: https://tesseract-ocr.github.io/tessdoc/",
        "DICOM PS3.15, Attribute Confidentiality Profiles: https://dicom.nema.org/medical/dicom/current/output/pdf/part15.pdf",
        "Presidio automated-detection limitation: https://microsoft.github.io/presidio/",
        "ZXing-C++ barcode formats and Python API: https://github.com/zxing-cpp/zxing-cpp",
        "PyMuPDF licensing: https://pymupdf.readthedocs.io/en/latest/about.html#license-and-copyright",
        "GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html",
    ]
    for reference in references:
        story.append(bullet(reference, s["small"]))
    story.append(Spacer(1, 8 * mm))
    story.append(
        p(
            "Документ является технической подготовкой. Он не заменяет заключение российского юриста по персональным данным, медицинской тайне, статусу оператора и допустимому основанию обработки.",
            s["body"],
        )
    )

    document.build(story)


def main() -> int:
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("output", type=Path)
    args = parser.parse_args()
    args.output.parent.mkdir(parents=True, exist_ok=True)
    build(args.output)
    print(args.output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
