"""Self-contained, accessible HTML; every numerical value comes from the current run."""
from html import escape
import json
from base64 import b64encode
from pathlib import Path

CSS = """
:root{color-scheme:light;--ink:#182e38;--muted:#465d66;--line:#cad6da;--accent:#00695c}
*{box-sizing:border-box}body{margin:0;background:#f7f8f6;color:var(--ink);font:17px/1.65 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
main{max-width:1100px;margin:auto;padding:38px 24px 70px}h1{font-size:clamp(1.8rem,4vw,2.65rem);line-height:1.16;max-width:950px;margin:12px 0 22px}
h2{font-size:1.55rem;line-height:1.3;margin:48px 0 14px;border-top:1px solid var(--line);padding-top:25px}h3{font-size:1.12rem;margin-top:26px}p,li{max-width:86ch}a{color:#005c70;text-underline-offset:3px}a:focus-visible,summary:focus-visible{outline:3px solid #ca7600;outline-offset:4px}
.kicker{font-size:.8rem;font-weight:750;letter-spacing:.07em;text-transform:uppercase;color:var(--accent)}.lead{font-size:1.15rem;color:var(--muted)}.note{background:#eaf1ef;border-left:4px solid var(--accent);padding:14px 20px;margin:22px 0}.small{font-size:.88rem;color:var(--muted)}
.table-wrap{overflow:auto;margin:18px 0 24px;background:white;border:1px solid var(--line);border-radius:6px}table{border-collapse:collapse;width:100%;font-size:.87rem}th,td{text-align:left;padding:11px 13px;border-bottom:1px solid #e1e7e8;vertical-align:top}th{background:#e9eff0;line-height:1.35}td.num{font-variant-numeric:tabular-nums;white-space:nowrap}tbody tr:last-child td{border:0}caption{text-align:left;padding:12px 14px;font-weight:700}pre{background:#17303a;color:#f4f7f6;padding:18px;border-radius:6px;overflow:auto;font-size:.88rem;line-height:1.5}code{font-size:.9em;overflow-wrap:anywhere}pre code{overflow-wrap:normal}details{margin:20px 0}summary{cursor:pointer;font-weight:650}nav{display:flex;gap:10px 20px;flex-wrap:wrap}nav a{font-size:.95rem}.warn{color:#874400;font-weight:650}footer{border-top:1px solid var(--line);margin-top:48px;padding-top:18px;font-size:.88rem;color:var(--muted)}
figure{margin:20px 0}figure .plot-wrap{overflow-x:auto;background:white;border:1px solid var(--line)}figure img{display:block;width:100%;min-width:900px;height:auto}figcaption{font-size:.85rem;color:var(--muted);margin-top:10px}
@media(max-width:600px){main{padding:25px 16px 45px}body{font-size:16px}th,td{padding:9px}h2{margin-top:36px}}
@media print{body{background:white;font-size:10pt}main{max-width:none;padding:0}h2{break-after:avoid}tr{break-inside:avoid}.table-wrap{overflow:visible}details{display:none}nav{display:none}a{color:inherit}}
"""


def document(title, body, language="en"):
    return f'<!doctype html><html lang="{language}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{escape(title)}</title><style>{CSS}</style></head><body><main>{body}<footer>LigninQC 1.0.0 · 5 September 2026 · Code: MIT; bundled published data and our original scientific material: CC BY 4.0 with attribution. These terms do not relicense user-supplied data. No external resources are required to read this report.</footer></main></body></html>\n'


def table(headers, rows, caption):
    head = "".join(f'<th scope="col">{escape(str(v))}</th>' for v in headers)
    body = "".join("<tr>" + "".join(f'<td class="{"num" if i else "label"}">{escape(str(v))}</td>' for i, v in enumerate(row)) + "</tr>" for row in rows)
    return f'<div class="table-wrap" role="region" aria-label="{escape(caption)}" tabindex="0"><table><caption>{escape(caption)}</caption><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>'


def f(value, digits=3):
    return "undefined" if value is None else f"{value:.{digits}f}"


def analysis_table(analyses, caption, russian=False):
    labels = {
        "DPPH: neutral forms, all available": "DPPH: нейтральные формы, все доступные",
        "DPPH: neutral, omit author-named 5 and 16": "DPPH: без названных авторами 5 и 16",
        "DPPH: neutral, monomers 1–14": "DPPH: нейтральные формы, мономеры 1–14",
        "DPPH: neutral, RDI per phenolic OH (reanalysis)": "DPPH: RDI на фенольную OH (новая нормировка)",
        "DPPH: last-listed BDE, chemical-mismatch sensitivity only": "DPPH: карбоксилаты — только проверка чувствительности к несоответствию формы",
        "ABTS: neutral BDE, chemical-mismatch sensitivity only": "ABTS: нейтральные формы — только проверка чувствительности к несоответствию формы",
        "ABTS: carboxylate-matched BDE, all available": "ABTS: карбоксилаты, все доступные",
        "ABTS: matched forms, omit author-named 5 and 16": "ABTS: карбоксилаты, без 5 и 16",
        "ABTS: matched forms, monomers 1–14": "ABTS: карбоксилаты, мономеры 1–14",
        "ABTS: matched forms, RDI per phenolic OH (reanalysis)": "ABTS: RDI на фенольную OH (новая нормировка)",
    }
    rows = []
    for a in analyses:
        rank = a["ranking"]
        loo = a["leave_one_out"]
        label = a.get("label", a["descriptor"] + " → " + a["response"])
        rows.append([labels.get(label, label) if russian else label, a["n"], f(a["r_squared"]), f(a["spearman_rho"]),
                     f'{rank["discordant"]}/{rank["concordant"] + rank["discordant"]}', f(loo["linear"]["mae"]), f(loo["training_mean_baseline"]["mae"])])
    headers = ["Вариант", "n", "R²", "ρ Спирмена", "Обратные / пары без совпадений", "LOO MAE: прямая", "LOO MAE: среднее"] if russian else ["Analysis", "n", "R²", "Spearman ρ", "Reversed / untied pairs", "LOO MAE: linear", "LOO MAE: mean"]
    return table(headers, rows, caption)


def generic_report(result, title):
    body = f'<p class="kicker">Reproducible numerical analysis</p><h1>{escape(title)}</h1>'
    if "analysis" in result:
        a = result["analysis"]
        body += analysis_table([a], "Descriptor model and training-mean baseline")
        body += f'<p>Input units: {escape(result["units"]["descriptor"])} → {escape(result["units"]["response"])}. Included {a["n"]} of {a["input_rows"]} rows. Expected association: {a["expected_direction"]}. Missing pairs: {escape(json.dumps(a["missing"]))}.</p>'
        body += '<p>R² describes an in-sample linear association. LOO MAE compares a linear fit and the mean of the training responses on identical held-out observations; lower is better. It is an internal diagnostic, without a claim of external validity. Reversed pairs disagree with the user-specified expected direction; exact ties are excluded from that denominator.</p>'
        body += table(["ID", "Descriptor", "Response"], [[p["id"], p["x"], p["y"]] for p in a["points"]], "Complete input pairs")
    elif "comparison" in result:
        c = result["comparison"]
        body += f'<p>{c["agreeing"]}/{c["n"]} aligned rows agree that the right-hand value is {c["expected_right"]} than the left-hand value. Units: {escape(result["unit"])}.</p>'
        body += table([c["index_column"], c["left_column"], c["right_column"], "Right − left", "Agrees"], [[r["index"], r["left"], r["right"], f(r["right_minus_left"]), r["agrees"]] for r in c["rows"]], "Every selected table row")
    body += '<p class="note">Inspect units, chemical states, selection rules and source provenance before using a numerical result as scientific evidence. The tool does not infer these from column names.</p>'
    body += '<details><summary>Full machine-readable result</summary><pre>' + escape(json.dumps(result, ensure_ascii=False, indent=2)) + '</pre></details>'
    return document(title, body)


def case_report(result):
    a = result["lauberte2019"]["analyses"]
    d, de, b = a["dpph_neutral_all"], a["dpph_author_exclusions"], a["abts_carboxylate_all"]
    t = result["kazachenko2022"]
    body = '''<p class="kicker">Открытые данные · воспроизводимый анализ · версия 1.0.0</p>
<h1>Что расчётная энергия связи объясняет в свойствах лигнина — и где вывод зависит от способа анализа</h1>
<p class="lead">Два законченных повторных анализа опубликованных данных: антиоксидантная активность модельных соединений и согласованность чисел при исследовании сульфатированного лигнина.</p>
<nav aria-label="Содержание"><a href="#results">Результаты</a><a href="#run">Повторить</a><a href="#methods">Методы</a><a href="#thermal">Термоанализ</a><a href="#use">Применение</a><a href="#limits">Границы</a><a href="#english">English</a></nav>
<div class="note">Это анализ опубликованных округлённых значений с проверяемой привязкой к источникам. Здесь не выполнялись новые DFT-расчёты или эксперименты. Отчёт не устанавливает общую точность автоматического рецензирования и не заменяет полноценный обзор литературы.</div>
<h2 id="results">1. Антиоксидантная активность: результат чувствителен к составу выборки</h2>
<p>В работе Lauberte и соавт. сопоставлены расчётные энтальпии диссоциации фенольной связи O–H (BDE) и индекс дезактивации радикалов (RDI). Чем выше RDI, тем больше радикалов дезактивировано. Энергия связи описывает термодинамику; сама по себе она не задаёт скорость реакции. <a href="https://doi.org/10.3390/molecules24091794">Первоисточник, таблица 1 и раздел 2</a>.</p>'''
    body += f'<p>Для DPPH по первым, нейтральным формам получено <strong>R² = {f(d["r_squared"])} при n = {d["n"]}</strong>. После исключения названных авторами соединений 5 и 16 результат возрастает до <strong>{f(de["r_squared"])} при n = {de["n"]}</strong>. Это показывает влияние состава выборки. Исключение наблюдений, выбранных после просмотра результатов, не доказывает лучшую предсказательную способность на новых соединениях.</p>'
    body += '<p class="small">На узком экране широкие таблицы и рисунок можно прокручивать вбок.</p>'
    body += analysis_table([d, de, b, a["dpph_per_phenolic_oh"]], "Основные сравнения; MAE выражена в единицах соответствующего RDI", True)
    body += '<p>LOO: по очереди убираем одно соединение, обучаем прямую на остальных и предсказываем убранное значение. Базовый прогноз — среднее только обучающих значений на тех же разбиениях. Меньшая MAE означает меньшую среднюю абсолютную ошибку. Столбец пар показывает, как часто меньшая BDE сопровождается меньшей, а не большей активностью; пары с равными значениями исключены. Эти пары не являются независимыми экспериментами.</p>'
    body += '<h3>Химический смысл выбора данных</h3><p>DPPH измеряли в метаноле; для карбоновых кислот в этой среде авторы рассматривают нейтральную форму. ABTS измеряли в водном буфере; для соединений 10–14 и 19 нужно учитывать карбоксилат. Парные BDE в таблице относятся к этим формам, а не к повторным измерениям. Расчётный растворитель в методах — метанол: выбор карбоксилата не превращает эти числа в полный расчёт водного буфера или равновесного состава.</p>'
    body += '<p>RDI моделей дан на молекулу, а соединения 16 и 17 содержат по две фенольные OH-группы. Деление на их число — отдельно обозначенный повторный анализ, а не исходный способ обработки авторов. В DPPH отсутствует значение для нерастворимого соединения 17; значения 15 и 16 заимствованы авторами из другой работы. В ABTS доступно 14 значений из 19. Пропуски не заменены нулями.</p>'
    body += analysis_table(list(a.values()), "Все десять заранее заданных вариантов: ни один не отобран по максимальному R²", True)
    posthoc = result["lauberte2019"]["posthoc_source_sensitivity"]
    body += f'<h3>Дополнительная проверка: в источнике расходятся и значения ABTS</h3><p>Таблица 1 даёт RDI 0,67 и 0,96 для соединений 5 и 6; текст раздела 2.2 — 1,45 и 1,44. Расхождение подтверждено и в XML, и визуально в PDF издателя (страницы 4 и 6). Исходная CSV сохраняет таблицу. В отдельной проверке, добавленной после обнаружения расхождения, заменены только эти два значения при неизменных остальных 12 наблюдениях и выборе карбоксилатов: R² меняется с {f(b["r_squared"])} до <strong>{f(posthoc["r_squared"])}</strong> при n = 14. Доверительные интервалы таблицы не перенесены на значения из текста.</p><p>Это показывает, насколько результат зависит от неоднозначности самого источника. Ни одна версия не объявлена истинной; этот дополнительный вариант не включён задним числом в заранее заданные десять анализов и не назван восстановлением авторской регрессии.</p>'
    figure = Path(__file__).resolve().parents[1] / "reference/figures/published-data-reanalysis.png"
    if figure.exists():
        encoded = b64encode(figure.read_bytes()).decode("ascii")
        body += f'<figure><div class="plot-wrap" tabindex="0" role="region" aria-label="Графики опубликованных данных"><img src="data:image/png;base64,{encoded}" alt="Связь BDE и табличной активности DPPH и ABTS; разности потерь массы при 13 температурах. Числа доступны в таблицах отчёта."></div><figcaption>Рисунок: опубликованные округлённые средние; линии — обычная линейная регрессия. Использованы все доступные пары, без исключения отмеченных номерами соединений. Интервалы неопределённости не показаны. SVG, PNG и PDF для повторного использования включены в архив. Новые эксперименты и DFT-расчёты не выполнялись.</figcaption></figure>'
    body += '<p class="small">Варианты проверки чувствительности к несоответствию формы показывают последствия неверного выбора формы; они не рекомендуются как физически корректные модели. Нормировка на фенольную OH-группу меняет целевую величину, поэтому её MAE нельзя напрямую трактовать как улучшение прогноза исходного RDI на молекулу. Опубликованные R² могут зависеть от неуказанного поднабора и неокруглённых данных: отличие пересчёта само по себе не является доказательством ошибки статьи.</p>'
    body += '''<h2 id="run">2. Повторить результат без учётной записи</h2>
<p>Нужен Python 3.10 или новее. Архив содержит код, исходные таблицы и XML статей, лицензии, эталонные результаты и тесты. Основной анализ работает без сети и установки библиотек.</p>
<p><a href="https://zack-dev-cm.github.io/docs/ligninqc/2026-09-05/ligninqc-reanalysis-2026-09-05.zip">Скачать полный архив</a> · <a href="https://zack-dev-cm.github.io/docs/ligninqc/2026-09-05/SHA256SUMS">Контрольная сумма</a> · <a href="https://zack-dev-cm.github.io/docs/ligninqc/2026-09-05/data/sources/source-extracts.html">Прочитать исходные таблицы и абзацы</a>. Эти же материалы находятся внутри архива для чтения без сети.</p>
<pre><code>unzip ligninqc-reanalysis-2026-09-05.zip
cd ligninqc-reanalysis-2026-09-05
python3 -m ligninqc reproduce --out results
python3 -m ligninqc verify
python3 tools/check_independent.py
python3 -m unittest discover -s tests -v</code></pre>
<p>Откройте <code>results/report.html</code>; числа и использованные строки находятся в <code>results/results.json</code>. В Windows распакуйте архив средствами системы и используйте <code>py -3</code> вместо <code>python3</code>. При повторном запуске выберите новый каталог либо добавьте <code>--force</code>. Проверка <code>verify</code> сопоставляет файлы с SHA-256, перечитывает числовые поля источников и сравнивает новый расчёт с эталоном. Это проверка воспроизводимости, а не научное рецензирование.</p>
<h2 id="methods">3. Как получены числа</h2>
<p>Использованы округлённые средние таблицы 1. Для каждого опыта и варианта оставлены только полные пары. Pearson r рассчитан по центрированным суммам, R² = r² для линейной регрессии с константой. Spearman ρ — корреляция средних рангов с учётом совпадений. Линейные прогнозы и прогнозы среднего проверены на одинаковых разбиениях leave-one-out. Все идентификаторы включённых, исключённых и пропущенных соединений, прогнозы отдельных разбиений и пары с обратным порядком записаны в JSON.</p>
<p>Интервалы после знака ± в источнике обозначены как доверительные при α = 0,05; они сохранены отдельно и не выданы за стандартные отклонения. Анализ невзвешенный: для неоднородных источников нет достаточных сырых данных, чтобы обосновать общую модель ошибок. Не рассчитывались p-значения, популяционные интервалы или мощность эксперимента. Малые и химически родственные выборки ограничивают перенос результата на новые соединения.</p>
<p>Первичные формы, поднаборы и методы зафиксированы в плане до реализации CLI, но после предварительного изучения этих статей. Это ретроспективная демонстрация, а не пререгистрация. Извлечение выполнено с помощью ИИ; числовые поля проверены отдельным парсером исходного XML и отдельным статистическим пересчётом. Независимая проверка учёными не заявляется.</p>
<h2 id="thermal">4. Термоанализ: таблица и текст дают разные присвоения</h2>
<p>В работе Kazachenko и соавт. таблица 2 содержит потери массы исходного и сульфатированного лигнина при 13 температурах (аргон, скорость нагревания 10 °C/мин). В тексте при 800 °C значения 61,9% и 76,6% приписаны образцам в обратном порядке относительно таблицы. Это проверяемая внутренняя несогласованность источника. По одной статье нельзя установить, какое присвоение соответствует исходному эксперименту. <a href="https://doi.org/10.3390/polym14153000">Первоисточник, таблица 2 и раздел термического анализа</a>.</p>'''
    body += table(["Температура, °C", "Исходный, %", "Сульфатированный, %", "Разность, п.п.", "Сульфатированный > исходного"],
                  [[f(r["index"], 0), f(r["left"], 1), f(r["right"], 1), f(r["right_minus_left"], 1), "да" if r["agrees"] else "нет"] for r in t["full_table"]["rows"]], "Полная таблица 2; разность = сульфатированный − исходный")
    body += '<p>Если распространить утверждение о большей потере массы на названный в абзаце диапазон от 250 °C, ему соответствует 11 из 12 табличных температур: при 250 °C разность равна −1,5 процентного пункта. Если рассмотреть всю таблицу, отрицательны две разности из 13, при 200 и 250 °C. Это два способа задать диапазон одной проверки; их нельзя считать двумя независимыми ошибками. Температуры одной кривой также не являются независимыми опытами.</p>'
    body += '''<h2 id="use">5. Как использовать результат в научной работе</h2>
<p><strong>Задача А — выбор дескриптора для обзора или планирования исследования.</strong> Укажите среду, форму молекулы и нормировку активности. Сохраните полный набор и покажите чувствительность к поднаборам; сравните прогноз с простым средним. Этот пакет уже выполняет такой анализ на реальных данных. Он помогает обосновать пределы BDE как единственного дескриптора, но не доказывает конкретный механизм реакции.</p>
<p><strong>Задача Б — извлечение надёжной числовой опоры из статьи.</strong> Используйте полную таблицу и локатор источника, проверяйте направление сравнения и присвоение чисел образцам. При обнаружении расхождения сохраняйте обе версии. Для данного случая корректная законченная запись — «внутренняя несогласованность при 800 °C», а не исправленная экспериментальная истина.</p>
<p>Для собственной CSV-таблицы задайте столбцы и единицы явно. Демонстрационный файл ниже синтетический и служит только примером формата; оба основных случая отчёта основаны на реальных статьях.</p>
<pre><code>python3 -m ligninqc analyze --csv examples/own-data.csv \
  --x bde_kcal_mol --y activity_rdi --id compound_id \
  --direction negative --x-unit kcal/mol --y-unit RDI --out my-results</code></pre>
<p>Команда <code>compare</code> применяет числовое сравнение к своей таблице. Полный синтаксис и готовая команда для термоанализа приведены в README. Инструмент не извлекает произвольные утверждения из PDF автоматически: для нового случая исследователь должен корректно задать данные, единицы, диапазон и смысл сравнения.</p>
<h2 id="limits">6. Что доказано и что остаётся за пределами результата</h2>
<p>Доказаны воспроизводимость указанных арифметических операций на включённых опубликованных таблицах и конкретные числовые выводы в заданных границах. Две статьи выбраны целенаправленно; на них нельзя оценить распространённость ошибок в области, общую точность инструмента или экономию рабочего времени у учёных. Сценарий использования проверяется автоматически и не назван исследованием с участием людей.</p>
<p>Пакет не выдаёт сдвиг GPC за доказанное расщепление связей, BDE за активационный барьер, удобный поднабор за внешний прогноз или прохождение тестов за научную новизну. Он не зависит от будущего доступа к платным статьям, лаборатории или ручной валидации для воспроизведения именно этих результатов. Более широкие химические гипотезы требуют других данных и здесь не объявлены решёнными.</p>
<h2 id="english" lang="en">English: completed results and scope</h2>'''
    body += f'<div lang="en"><p>This release reanalyzes published lignin evidence with a Python standard-library CLI and bundled CC BY source data. DPPH neutral-form BDE versus RDI gives R² = {f(d["r_squared"])} (n={d["n"]}); removing author-named compounds 5/16 yields {f(de["r_squared"])} (n={de["n"]}). State, subset and phenolic-OH normalization alternatives are reported together with same-fold leave-one-out linear and training-mean baselines.</p><p>Kazachenko Table 2 assigns 61.9% mass loss to initial lignin and 76.6% to sulfated lignin at 800 °C; the prose reverses those assignments. Applying the paragraph’s greater-loss statement to its described range from 250 °C gives one tabular counterexample among 12 temperatures. These are source-consistency findings, not corrected raw experimental measurements.</p><p>No new DFT, laboratory experiment, general accuracy estimate, external prediction validation or human usability study is claimed. The completed deliverable is a reusable, offline secondary-analysis workflow with auditable results, not a comprehensive or peer-reviewed literature synthesis.</p></div>'
    body += '<p lang="en">An additional source conflict affects ABTS: Table 1 reports 0.67/0.96 for compounds 5/6, while §2.2 reports 1.45/1.44. Replacing only these two outcomes in a separately labelled post-hoc sensitivity changes matched-state R² from 0.580 to 0.747 for the same 14 observations. Neither representation is adjudicated, and the table confidence intervals are not transferred to the prose values.</p>'
    body += '<h2>Источники и повторное использование</h2><p>Lauberte et al. (2019), <a href="https://doi.org/10.3390/molecules24091794">Molecules 24, 1794</a>; Kazachenko et al. (2022), <a href="https://doi.org/10.3390/polym14153000">Polymers 14, 3000</a>. Оба источника опубликованы по CC BY 4.0. Сохранены авторство, DOI, локаторы и лицензии. Код — MIT; новый текст и анализ — CC BY 4.0. Подробные права, цитирование и перечень изменений находятся в DATA-LICENSE.md и CITATION.cff внутри архива.</p>'
    return document("LigninQC — воспроизводимый анализ данных о лигнине", body, "ru")
