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

"""Build a privacy-safe PDF report for a confirmed-synthetic corpus run."""

from __future__ import annotations

import argparse
import json
import statistics
from collections import Counter
from pathlib import Path
from typing import Any

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("#143127")
GREEN = colors.HexColor("#28513f")
LIME = colors.HexColor("#b8e65b")
PAPER = colors.HexColor("#f6f2e9")
MUTED = colors.HexColor("#617069")
LINE = colors.HexColor("#d5ddd7")
AMBER = colors.HexColor("#d69e2e")
RED = colors.HexColor("#b13f3f")
BLUE = colors.HexColor("#3f6d91")


CATEGORY_LABELS = {
    "address": "Адрес",
    "barcode": "Штрихкод / QR",
    "birth_date": "Дата рождения",
    "email": "Email",
    "event_date": "Календарная дата",
    "insurance_policy": "Полис",
    "medical_record_id": "Медицинский номер",
    "passport": "Паспорт",
    "person_name": "ФИО",
    "phone": "Телефон",
    "snils": "СНИЛС",
}


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("QARegular", str(regular)))
    pdfmetrics.registerFont(TTFont("QABold", str(bold if bold.exists() else regular)))


class MetricCard(Flowable):
    def __init__(self, width: float, value: str, label: str, accent=GREEN) -> None:
        super().__init__()
        self.width = width
        self.height = 27 * mm
        self.value = value
        self.label = label
        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 - 7 * mm, 17 * mm, 3 * mm, 1.5 * mm, fill=1, stroke=0)
        canvas.setFillColor(INK)
        canvas.setFont("QABold", 18)
        canvas.drawString(5 * mm, 11 * mm, self.value)
        canvas.setFillColor(MUTED)
        canvas.setFont("QARegular", 7.6)
        canvas.drawString(5 * mm, 5 * mm, self.label)


class CategoryBars(Flowable):
    def __init__(self, width: float, counts: dict[str, int]) -> None:
        super().__init__()
        self.width = width
        self.counts = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
        self.height = max(30 * mm, len(self.counts) * 8.3 * mm + 7 * mm)

    def draw(self) -> None:
        canvas = self.canv
        label_width = 47 * mm
        bar_width = self.width - label_width - 18 * mm
        maximum = max((value for _, value in self.counts), default=1)
        y = self.height - 7 * mm
        for category, value in self.counts:
            label = CATEGORY_LABELS.get(category, category)
            canvas.setFont("QARegular", 8.5)
            canvas.setFillColor(INK)
            canvas.drawString(0, y, label)
            canvas.setFillColor(LINE)
            canvas.roundRect(label_width, y - 1.2 * mm, bar_width, 3.4 * mm, 1.5 * mm, fill=1, stroke=0)
            canvas.setFillColor(GREEN if category != "event_date" else AMBER)
            canvas.roundRect(
                label_width,
                y - 1.2 * mm,
                max(1.2 * mm, bar_width * value / maximum),
                3.4 * mm,
                1.5 * mm,
                fill=1,
                stroke=0,
            )
            canvas.setFillColor(INK)
            canvas.setFont("QABold", 8.5)
            canvas.drawRightString(self.width, y, f"{value:,}".replace(",", " "))
            y -= 8.3 * mm


def styles() -> dict[str, ParagraphStyle]:
    base = getSampleStyleSheet()
    return {
        "title": ParagraphStyle(
            "Title",
            parent=base["Title"],
            fontName="QABold",
            fontSize=27,
            leading=31,
            textColor=colors.white,
            alignment=TA_LEFT,
            spaceAfter=5 * mm,
        ),
        "subtitle": ParagraphStyle(
            "Subtitle",
            parent=base["BodyText"],
            fontName="QARegular",
            fontSize=12,
            leading=17,
            textColor=colors.white,
        ),
        "h1": ParagraphStyle(
            "H1",
            parent=base["Heading1"],
            fontName="QABold",
            fontSize=19,
            leading=23,
            textColor=INK,
            spaceBefore=4 * mm,
            spaceAfter=4 * mm,
        ),
        "h2": ParagraphStyle(
            "H2",
            parent=base["Heading2"],
            fontName="QABold",
            fontSize=12.5,
            leading=15,
            textColor=GREEN,
            spaceBefore=3.5 * mm,
            spaceAfter=2 * mm,
        ),
        "body": ParagraphStyle(
            "Body",
            parent=base["BodyText"],
            fontName="QARegular",
            fontSize=9.5,
            leading=13.7,
            textColor=INK,
            spaceAfter=2.5 * mm,
        ),
        "small": ParagraphStyle(
            "Small",
            parent=base["BodyText"],
            fontName="QARegular",
            fontSize=7.8,
            leading=10.5,
            textColor=MUTED,
        ),
        "bullet": ParagraphStyle(
            "Bullet",
            parent=base["BodyText"],
            fontName="QARegular",
            fontSize=9.2,
            leading=13,
            textColor=INK,
            leftIndent=5 * mm,
            firstLineIndent=-3.2 * mm,
            spaceAfter=1.5 * mm,
        ),
        "table": ParagraphStyle(
            "Table",
            parent=base["BodyText"],
            fontName="QARegular",
            fontSize=8.1,
            leading=10.3,
            textColor=INK,
        ),
        "table_head": ParagraphStyle(
            "TableHead",
            parent=base["BodyText"],
            fontName="QABold",
            fontSize=8.1,
            leading=10.3,
            textColor=colors.white,
        ),
        "center": ParagraphStyle(
            "Center",
            parent=base["BodyText"],
            fontName="QABold",
            fontSize=11,
            leading=14,
            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 alert(text: str, s: dict[str, ParagraphStyle], color=RED) -> Paragraph:
    return p(
        text,
        ParagraphStyle(
            "Alert",
            parent=s["body"],
            textColor=color,
            borderColor=color,
            borderWidth=0.7,
            borderPadding=7,
            backColor=colors.HexColor("#fff7ef") if color == AMBER else colors.HexColor("#fff2f0"),
        ),
    )


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.setFillColor(MUTED)
    canvas.setFont("QARegular", 8)
    canvas.drawString(20 * mm, 9.5 * mm, "MIKA De-ID Local 0.2.1 | NPO NT | synthetic corpus QA | 27.08.2026")
    canvas.drawRightString(width - 20 * mm, 9.5 * mm, str(document.page))
    canvas.restoreState()


def load_records(analysis_dir: Path) -> list[dict[str, Any]]:
    records = []
    for path in sorted(analysis_dir.glob("document-*.analysis.json")):
        records.append(json.loads(path.read_text(encoding="utf-8")))
    return records


def compute_document_coverage(records: list[dict[str, Any]]) -> Counter[str]:
    coverage: Counter[str] = Counter()
    for record in records:
        if record.get("status") != "ok":
            continue
        for category, count in record["analysis"]["category_counts"].items():
            if count:
                coverage[category] += 1
    return coverage


def build(
    summary_path: Path,
    analysis_dir: Path,
    output_path: Path,
    registry_evaluation_path: Path | None = None,
) -> None:
    register_fonts()
    summary = json.loads(summary_path.read_text(encoding="utf-8"))
    registry_evaluation = (
        json.loads(registry_evaluation_path.read_text(encoding="utf-8"))
        if registry_evaluation_path is not None
        else None
    )
    records = load_records(analysis_dir)
    if len(records) != int(summary["document_count"]):
        raise ValueError("document analysis count does not match corpus summary")
    if summary.get("corpus_classification") != "CONFIRMED_FULLY_SYNTHETIC":
        raise ValueError("report generator accepts only a confirmed-synthetic corpus")

    successful = [record for record in records if record.get("status") == "ok"]
    document_coverage = compute_document_coverage(records)
    findings_per_document = [record["analysis"]["finding_count"] for record in successful]
    zero_finding_documents = sum(value == 0 for value in findings_per_document)
    pages_total = int(summary["total_pages"])
    pages_without_findings = int(summary["pages_without_findings"])
    page_coverage_percent = (
        round((pages_total - pages_without_findings) / pages_total * 100, 1)
        if pages_total
        else 0.0
    )
    redact_count = int(summary["action_counts"].get("redact", 0))
    review_count = int(summary["action_counts"].get("review", 0))
    elapsed = float(summary["elapsed_seconds"])

    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 — synthetic corpus QA",
        author="NPO NT",
        subject="Privacy-safe quality report for a fully synthetic medical-document corpus",
    )
    document.addPageTemplates([PageTemplate(id="default", frames=[frame], onPage=page_decor)])
    s = styles()
    story: list[Any] = []

    cover = Table(
        [
            [p("MIKA De-ID Local 0.2.1", s["title"])],
            [p("Отчёт о полном техническом прогоне синтетического корпуса медицинских документов", s["subtitle"])],
        ],
        colWidths=[width - 40 * mm],
        rowHeights=[36 * mm, 40 * 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),
            ]
        )
    )
    story.extend([Spacer(1, 24 * mm), cover, Spacer(1, 10 * mm)])
    card_gap = 3 * mm
    card_width = (width - 40 * mm - 2 * card_gap) / 3
    story.append(
        Table(
            [[
                MetricCard(card_width, str(summary["document_count"]), "PDF-документов"),
                MetricCard(card_width, str(pages_total), "страниц"),
                MetricCard(card_width, f"{summary['total_findings']:,}".replace(",", " "), "кандидатов"),
            ]],
            colWidths=[card_width, card_width, card_width],
            hAlign="LEFT",
            style=TableStyle(
                [
                    ("LEFTPADDING", (0, 0), (-1, -1), 0),
                    ("RIGHTPADDING", (0, 0), (-1, -1), card_gap),
                    ("RIGHTPADDING", (-1, 0), (-1, 0), 0),
                    ("TOPPADDING", (0, 0), (-1, -1), 0),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
                ]
            ),
        )
    )
    story.append(Spacer(1, 7 * mm))
    story.append(p("Статус: LOCAL / SYNTHETIC DATA ONLY / HUMAN REVIEW REQUIRED", s["center"]))
    story.append(Spacer(1, 5 * mm))
    story.append(
        p(
            "Корпус полностью синтетический по письменному подтверждению владельца проекта. Этот прогон проверяет техническую работоспособность пакетного OCR, локальных правил и безопасного отчёта. Он не является доказательством полного обезличивания и не даёт разрешения на реальные пациентские документы.",
            s["body"],
        )
    )
    story.append(PageBreak())

    story.append(p("1. Итог прогона", s["h1"]))
    result_rows = [
        ["Показатель", "Результат", "Интерпретация"],
        ["Успешно обработано", f"{summary['successful_documents']} / {summary['document_count']}", "Техническое завершение OCR"],
        ["Ошибки документов", str(summary["failed_documents"]), "Ноль требуется для smoke-test"],
        ["Время", f"{elapsed / 60:.1f} мин", "Локально, два процесса"],
        ["Пропускная способность", f"{summary['throughput_pages_per_minute']:.1f} стр./мин", "Зависит от компьютера и качества страниц"],
        ["Кандидаты на удаление", f"{redact_count:,}".replace(",", " "), "Автоматически выделены, не подтверждены человеком"],
        ["Кандидаты на решение", f"{review_count:,}".replace(",", " "), "В основном точные клинические даты"],
        ["Страницы с находками", f"{page_coverage_percent}%", "Не является recall"],
        ["Документы без находок", str(zero_finding_documents), "Требуют обязательного ручного просмотра"],
    ]
    story.append(table(result_rows, [49 * mm, 37 * mm, 74 * mm], s))
    story.append(Spacer(1, 4 * mm))
    story.append(
        alert(
            "Recall и precision не вычислялись: в переданном наборе нет координатной эталонной разметки. Количество найденных областей — это объём машинной предразметки, а не доказательство, что пропусков нет.",
            s,
            AMBER,
        )
    )

    story.append(p("Контроль приватности отчёта", s["h2"]))
    for text in [
        "OCR-текст и найденные значения не экспортировались.",
        "Исходные имена файлов и SHA-256 отдельных документов не экспортировались.",
        "В публичный исходный релиз сам корпус, реестр и предразметка не включаются.",
        "Файлы анализа сохранены локально с правами 600, каталог — 700.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(PageBreak())
    story.append(p("2. Структура найденных кандидатов", s["h1"]))
    story.append(CategoryBars(width - 40 * mm, summary["category_counts"]))
    story.append(Spacer(1, 4 * mm))
    category_rows = [["Категория", "Областей", "Документов", "Политика"]]
    for category, count in sorted(
        summary["category_counts"].items(), key=lambda item: (-item[1], item[0])
    ):
        policy = "решение человека" if category == "event_date" else "удалить"
        category_rows.append(
            [
                CATEGORY_LABELS.get(category, category),
                f"{count:,}".replace(",", " "),
                f"{document_coverage.get(category, 0)} / {summary['successful_documents']}",
                policy,
            ]
        )
    story.append(table(category_rows, [55 * mm, 30 * mm, 35 * mm, 40 * mm], s))
    story.append(Spacer(1, 3 * mm))
    story.append(
        p(
            "Покрытие по документам показывает только наличие хотя бы одного кандидата соответствующей категории. Оно не подтверждает обнаружение каждого экземпляра внутри документа.",
            s["small"],
        )
    )

    story.append(PageBreak())
    story.append(p("3. OCR, штрихкоды и качество страниц", s["h1"]))
    confidence = summary["ocr_confidence_summary_from_document_extrema_and_medians"]
    quality_rows = [
        ["Проверка", "Значение"],
        ["OCR-языки", "rus + eng, полностью локально"],
        ["Страницы ниже внутреннего порога OCR", str(summary["ocr_pages_below_confidence_threshold"])],
        ["Страницы без оценки OCR", str(summary["ocr_pages_without_confidence"])],
        ["Диапазон агрегированных OCR-оценок", f"{confidence['min']} — {confidence['max']}"],
        ["Медиана агрегированных OCR-оценок", str(confidence["median"])],
        ["Машиночитаемые коды", f"{summary['category_counts'].get('barcode', 0)} кандидатов"],
        ["Предупреждения анализатора", str(sum(summary["warning_type_counts"].values()))],
    ]
    story.append(table(quality_rows, [82 * mm, 78 * mm], s))
    story.append(p("Как читать OCR confidence", s["h2"]))
    story.append(
        p(
            "Это внутренняя оценка распознавания символов, а не вероятность корректного обезличивания. Высокий confidence не исключает пропущенное имя, рукопись, подпись, лицо, штамп или идентификатор в неожиданном месте.",
            s["body"],
        )
    )
    story.append(p("Ограничения текущего детектора", s["h2"]))
    for text in [
        "ФИО и дата рождения могут быть распознаны как другая категория либо не распознаны из-за верстки или качества скана.",
        "Лица, подписи, рукопись и редкие косвенные идентификаторы пока не имеют доказанного автоматического покрытия.",
        "Точные клинические даты намеренно не удаляются без решения человека: они могут быть важны для медицинской навигации.",
        "Каждая страница, включая страницу без машинных находок, должна быть видима в ручной проверке.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(PageBreak())
    story.append(p("4. Реестр и связь документов", s["h1"]))
    registry_rows = [
        ["Объект", "Результат"],
        ["Синтетический реестр", "1 XLSX, 101 строка × 20 столбцов"],
        ["Строки данных", "100"],
        ["ФИО", "100 заполненных, 100 уникальных"],
        ["Дата рождения", "100 заполненных, 99 уникальных"],
        ["Формулы", "0"],
        ["Связь PDF ↔ строка", "99 прямых уникальных совпадений + 1 вывод по оставшейся паре"],
    ]
    story.append(table(registry_rows, [61 * mm, 99 * mm], s))
    story.append(Spacer(1, 4 * mm))
    story.append(
        p(
            "Связь построена локально по внутренним синтетическим идентификаторам из исходных имён архива. В карту соответствий не записаны ФИО, даты рождения, исходные имена файлов или значения реестра. Единственная выведенная пара отдельно помечена как inference.",
            s["body"],
        )
    )
    story.append(
        alert(
            "Сам реестр не является координатной разметкой PDF. Он помогает контролировать состав корпуса, но не может служить эталоном для расчёта точности областей.",
            s,
            AMBER,
        )
    )
    if registry_evaluation is not None:
        story.append(p("Проверка синтетических значений", s["h2"]))
        value_rows = [
            ["Показатель", "Документов"],
            ["Синтетическое ФИО распознано OCR", f"{registry_evaluation['name_value_recognized_documents']} / {registry_evaluation['successful_documents']}"],
            ["Синтетическая дата рождения распознана OCR", f"{registry_evaluation['birth_date_value_recognized_documents']} / {registry_evaluation['successful_documents']}"],
            ["ФИО классифицировано как person_name", f"{registry_evaluation['name_classified_person_name_documents']} / {registry_evaluation['successful_documents']}"],
            ["Дата классифицирована как birth_date", f"{registry_evaluation['birth_date_classified_birth_date_documents']} / {registry_evaluation['successful_documents']}"],
            ["Дата классифицирована как event_date", f"{registry_evaluation['birth_date_classified_event_date_documents']} / {registry_evaluation['successful_documents']}"],
        ]
        story.append(table(value_rows, [102 * mm, 58 * mm], s))
        story.append(Spacer(1, 2 * mm))
        story.append(
            p(
                "Это value-level проверка по синтетическому реестру. Она показывает точное распознавание ожидаемого значения и его классификацию в OCR-строке, но не является независимой координатной разметкой и не позволяет вычислять precision.",
                s["small"],
            )
        )
        story.append(Spacer(1, 2 * mm))
        story.append(
            alert(
                "Низкое число точных совпадений — блокирующий сигнал для разметки и исследования OCR. Оно не является формальным miss rate: реестр не доказывает видимое присутствие каждого значения в PDF, а одна ошибка OCR разрушает точное совпадение.",
                s,
                AMBER,
            )
        )

    story.append(p("5. Машинная предразметка", s["h1"]))
    for text in [
        "Для каждого документа формируется JSON по схеме mika-deid-annotation-v1.",
        "Координаты нормализуются к диапазону 0..1; фактический текст не записывается.",
        "Каждая область имеет human_reviewed=false и статус MACHINE_PREANNOTATION_NOT_GROUND_TRUTH.",
        "Предразметка ускоряет работу разметчиков, но не используется как locked-validation до двойной проверки и разрешения расхождений.",
    ]:
        story.append(bullet(text, s["bullet"]))

    story.append(PageBreak())
    story.append(p("6. Что требуется от разметчиков", s["h1"]))
    steps = [
        ["Шаг", "Действие", "Результат"],
        ["1", "Просмотреть каждую страницу и каждую машинную область", "Подтверждённые / отклонённые области"],
        ["2", "Добавить все пропущенные идентификаторы", "Полная координатная разметка"],
        ["3", "Отдельно отметить лица, подписи, рукопись и косвенные идентификаторы", "Покрытие сложных категорий"],
        ["4", "Второй разметчик независимо проверяет критические категории", "Двойной контроль"],
        ["5", "Арбитр разрешает расхождения", "Adjudicated ground truth"],
        ["6", "Разделить по человеку/случаю на calibration и locked-validation", "Отсутствие утечки между наборами"],
        ["7", "Запустить оценку отдельно по каждой категории", "Recall, precision и список пропусков"],
    ]
    story.append(table(steps, [14 * mm, 82 * mm, 64 * mm], s))
    story.append(Spacer(1, 4 * mm))
    story.append(
        alert(
            "Любой пропуск прямого идентификатора в locked-validation блокирует переход к закрытому пилоту до исправления причины и полного повторного прогона.",
            s,
        )
    )

    story.append(p("Минимальные категории обязательной разметки", s["h2"]))
    story.append(
        p(
            "ФИО, дата рождения, телефон, email, СНИЛС, паспорт, полис, медицинский номер, адрес, штрихкод/QR, подпись, лицо, организация, точная дата и косвенный идентификатор.",
            s["body"],
        )
    )

    story.append(PageBreak())
    story.append(p("7. Решение по релизному состоянию", s["h1"]))
    state_rows = [
        ["Состояние", "Вердикт", "Что это означает"],
        ["Открытый исходный код", "готов к публикации 0.2.1", "AGPL-3.0-only, NPO NT, source archive и Git"],
        ["Локальный синтетический прогон", "пройден", "100 PDF / 453 страницы / 0 ошибок"],
        ["Машинная предразметка", "готова для review", "Не ground truth"],
        ["Закрытый пилот", "не разрешён", "Нет adjudicated locked-validation и независимых проверок"],
        ["Реальные документы пациентов", "не разрешены", "Нужны правовой, privacy, security и эксплуатационный контуры"],
        ["Гарантия анонимности", "не заявляется", "Инструмент снижает риск, но не сертифицирует результат"],
    ]
    story.append(table(state_rows, [48 * mm, 40 * mm, 72 * mm], s))
    story.append(Spacer(1, 4 * mm))
    story.append(
        p(
            "Вывод: версия 0.2.1 пригодна как открытый локальный прототип и рабочее место для дальнейшей разметки синтетического корпуса. Переход к реальным данным не допускается автоматически после этого отчёта.",
            s["body"],
        )
    )

    story.append(p("8. Воспроизводимость", s["h1"]))
    reproducibility_rows = [
        ["Артефакт", "Значение"],
        ["Схема отчёта", str(summary["schema"])],
        ["Классификация корпуса", str(summary["corpus_classification"])],
        ["Пер-документные отчёты", f"{len(records)} JSON, без OCR-текста"],
        ["Версия продукта", "MIKA De-ID Local 0.2.1"],
        ["Правообладатель", "NPO NT / НПО НТ"],
        ["Лицензия", "GNU AGPLv3 only"],
    ]
    story.append(table(reproducibility_rows, [60 * mm, 100 * mm], s))
    story.append(Spacer(1, 8 * mm))
    story.append(
        p(
            "Отчёт не содержит исходные медицинские тексты. Технические агрегаты необходимо хранить вместе с соответствующим commit, tag, lock-файлом зависимостей и хешем исходного архива корпуса.",
            s["small"],
        )
    )

    output_path.parent.mkdir(parents=True, exist_ok=True)
    document.build(story)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("summary", type=Path)
    parser.add_argument("analysis_dir", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--registry-evaluation", type=Path)
    args = parser.parse_args()
    build(args.summary, args.analysis_dir, args.output, args.registry_evaluation)
    print(args.output)
    return 0


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