#!/usr/bin/env python3
"""Fill only the two published templates; verify without rewriting DOCX XML."""
import argparse
import datetime as dt
import hashlib
from functools import lru_cache
import io
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import unicodedata
import xml.etree.ElementTree as ET
import zipfile

ROOT = Path(__file__).resolve().parent.parent
# Updated explicitly by scripts/build_contract_release.py after review.
RELEASE_SHA256 = '263f560ca56a512d029a0cd83b3323a774b11b35c72a593eed994bc2b3c35f2e'
MAX_JSON = 512 * 1024
MAX_DOCX = 8 * 1024 * 1024
MAX_PART = 8 * 1024 * 1024
MAX_EXPANDED = 32 * 1024 * 1024
MAX_SOURCES = 64
MAX_SNAPSHOT = 2 * 1024 * 1024
MONTHS = 'января февраля марта апреля мая июня июля августа сентября октября ноября декабря'.split()
LABELS = dict(name_full='Полное наименование', name_short='Сокращённое наименование',
              signatory='Подписант', basis='Основание полномочий', address='Юридический адрес',
              postal_address='Почтовый адрес', inn='ИНН', kpp='КПП', ogrn='ОГРН',
              rs='Расчётный счёт', ks='Корреспондентский счёт', bik='БИК', bank='Банк',
              auth_email='Авторизованная почта', notify_email='Почта для уведомлений', domain='Домен')


def compact(value):
    return re.sub(r'\s+', ' ', str(value)).strip()


def confirmation(data, tpl, ctx):
    values = {'/template': data['template'], '/contract_date': data['contract_date'],
              '/contract_number': ctx['contract_number']}
    lines = [f"Договор: {tpl['title']}", f"Дата договора: {ctx['contract_date']}",
             f"Номер договора: {ctx['contract_number']}"]
    for key in tpl['licensee_fields']:
        values['/licensee/' + key] = data['licensee'][key]
        lines.append(f"{LABELS[key]}: {compact(data['licensee'][key])}")
    if data['template'] == 'corporation':
        titles = dict(name='название лицензии', months='срок в месяцах', qty='количество лицензий', price='цена в рублях за лицензию в месяц')
        for i, line in enumerate(data['order']['lines']):
            for key, title in titles.items():
                value = line[key]
                values[f'/order/lines/{i}/{key}'] = value
                lines.append(f'Строка {i+1}, {title}: {compact(value)}')
        values['/calculated/spec_total'] = ctx['spec_total']
        lines.append(f"Итого по спецификации, руб.: {ctx['spec_total']}")
    for i, change in enumerate(data['changes']):
        for key, label in dict(location='место', before='было', after='стало', reason='причина', user_quote='запрос пользователя').items():
            values[f'/changes/{i}/{key}'] = change[key]
            lines.append(f'Изменение {i+1}, {label}: {change[key]}')
    values['/source_files'] = data['source_files']
    for document, record in sorted(data['source_files'].items()):
        lines.append(f"Источник: {document}; файл: {record['path']}; SHA-256: {record['sha256']}")
    return values, lines


def approval_digest(data):
    # The user's returned digest is supplied separately, never read from approval.
    snapshot = {key: value for key, value in data.items() if key != 'approval'}
    encoded = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8')
    return hashlib.sha256(encoded).hexdigest()


def approval_text(data, tpl, ctx):
    _, lines = confirmation(data, tpl, ctx)
    return 'Подтверждаю следующие данные:\n' + '\n'.join(lines) + '\nКод подтверждения SHA-256: ' + approval_digest(data)


class Failure(Exception):
    def __init__(self, code, field, message, next_step):
        self.problem = dict(code=code, field=field, message=message, next_step=next_step)


def fail(code, field, message, next_step):
    raise Failure(code, field, message, next_step)


def read_bytes(path, limit):
    try:
        fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
        with os.fdopen(fd, 'rb') as handle:
            info = os.fstat(handle.fileno())
            if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
                raise ValueError('Нужен обычный файл в пределах ограничения размера.')
            body = handle.read(limit + 1)
            if len(body) > limit:
                raise ValueError('Превышено ограничение размера файла.')
            return body
    except (OSError, ValueError) as exc:
        fail('READ_FILE', str(path), str(exc), 'Проверьте обычный файл, его размер и права; символические ссылки не используются.')


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('Повторяющийся ключ JSON: ' + key)
        result[key] = value
    return result


def parse_json(body):
    return json.loads(body, object_pairs_hook=unique_object,
                      parse_constant=lambda value: (_ for _ in ()).throw(ValueError('Недопустимое число: ' + value)))


def read_json(path):
    try:
        return parse_json(read_bytes(path, MAX_JSON))
    except (ValueError, RecursionError) as exc:
        fail('READ_JSON', str(path), str(exc), 'Проверьте путь и синтаксис JSON; сохраните исходное сообщение.')


def encoded(value):
    return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + '\n').encode('utf-8')


def absolute_path(path):
    return str(Path(os.path.abspath(Path(path).expanduser())))


def source_documents(data):
    sources = data.get('sources')
    if not isinstance(sources, dict) or not sources:
        fail('MISSING_SOURCE', '/sources', 'Нет записей источников.', 'Сначала сохраните документ и дословную цитату для каждого исходного поля.')
    documents = set()
    for pointer, evidence in sources.items():
        if not isinstance(evidence, dict) or set(evidence) != {'document', 'quote'}:
            fail('MISSING_SOURCE', pointer, 'Нужны document и quote.', 'Укажите реальный источник и дословный фрагмент.')
        documents.add(text(evidence['document'], pointer + ':document'))
        text(evidence['quote'], pointer + ':quote', multiline=True)
    if len(documents) > MAX_SOURCES:
        fail('SOURCE_COUNT', '/sources', 'Превышено число документов-источников.', 'Сохраните исходные документы; не объединяйте или обрезайте их молча.')
    return documents


def bound_source_files(data, verify_hashes=True):
    documents = source_documents(data)
    records = data.get('source_files')
    if not isinstance(records, dict) or set(records) != documents:
        fail('SOURCE_FILES', '/source_files', 'Файлы источников не соответствуют document из sources.', 'Создайте карту document → путь реального файла и выполните --bind-sources с --bound-data.')
    total = 0
    checks = []
    for document, record in records.items():
        if not isinstance(record, dict) or set(record) != {'path', 'sha256'}:
            fail('SOURCE_FILE_FIELDS', document, 'Нужны path и sha256 файла источника.', 'Не придумывайте отпечаток: используйте --bind-sources.')
        path = text(record['path'], document + ':path')
        if verify_hashes and (not Path(path).is_absolute() or absolute_path(path) != path):
            fail('SOURCE_FILE_PATH', document, 'Нужен абсолютный путь, полученный при привязке источников.', 'Выполните --bind-sources заново и получите новое подтверждение.')
        if not isinstance(record['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', record['sha256']):
            fail('SOURCE_FILE_DIGEST', document, 'Недопустимый SHA-256 источника.', 'Выполните --bind-sources, не заполняйте отпечатки вручную.')
        if verify_hashes:
            body = read_bytes(path, MAX_DOCX)
            total += len(body)
            if total > MAX_EXPANDED:
                fail('SOURCE_SIZE', '/source_files', 'Превышен общий размер источников.', 'Сохраните оригиналы и уточните допустимый способ обработки; не обрезайте файлы.')
            if hashlib.sha256(body).hexdigest() != record['sha256']:
                fail('SOURCE_FILE_HASH', document, 'Источник изменился после привязки к данным.', 'Сравните его со снимком. Уточните изменения, повторите привязку и получите новое подтверждение.')
            checks.append(source_quotes(data, document, path, body))
    return checks


def source_quotes(data, document, path, body):
    suffix = Path(path).suffix.lower()
    source_text = None
    try:
        if suffix in ('.txt', '.md'):
            source_text = body.decode('utf-8-sig')
        elif suffix == '.docx':
            parts = archive_parts(body)
            root = ET.fromstring(parts['word/document.xml'])
            ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
            paragraphs = []
            for paragraph in root.iter(ns + 'p'):
                pieces = []
                for node in paragraph.iter():
                    if node.tag == ns + 't':
                        pieces.append(node.text or '')
                    elif node.tag == ns + 'tab':
                        pieces.append('\t')
                    elif node.tag in (ns + 'br', ns + 'cr'):
                        pieces.append('\n')
                paragraphs.append(''.join(pieces))
            source_text = '\n'.join(paragraphs)
    except (ValueError, UnicodeError, KeyError, ET.ParseError, zipfile.BadZipFile) as exc:
        fail('SOURCE_TEXT', document, str(exc), 'Сохраните оригинал и проверьте формат. Не перекодируйте и не обходите ошибку молча.')
    pointers = [pointer for pointer, evidence in data['sources'].items() if evidence['document'] == document]
    if source_text is not None:
        source_text = source_text.replace('\r\n', '\n')
        for pointer in pointers:
            quote = data['sources'][pointer]['quote'].replace('\r\n', '\n')
            if quote not in source_text:
                fail('SOURCE_QUOTE', pointer, 'Дословная цитата отсутствует в сохранённом текстовом источнике.', 'Откройте оригинал и уточните точную цитату и поле. Не создавайте файл источника из уже введённых значений.')
    return dict(document=document, sha256=hashlib.sha256(body).hexdigest(),
                quotes_checked=source_text is not None, method='exact_text' if source_text is not None else 'independent_review_required',
                fields=pointers, source_authenticity_verified=False)


def bind_sources(data, mapping):
    documents = source_documents(data)
    if not isinstance(mapping, dict) or set(mapping) != documents:
        fail('SOURCE_MAP', '--bind-sources', 'Карта должна содержать ровно все document из sources.', 'Для каждого документа или фактического сообщения укажите путь сохранённого оригинала; не создавайте сообщения за пользователя.')
    bound = parse_json(encoded(data))
    bound['source_files'] = {}
    total = 0
    for document, path in sorted(mapping.items()):
        path = absolute_path(text(path, document + ':path'))
        body = read_bytes(path, MAX_DOCX)
        total += len(body)
        if total > MAX_EXPANDED:
            fail('SOURCE_SIZE', '--bind-sources', 'Превышен общий размер источников.', 'Сохраните оригиналы; не обрезайте их молча.')
        bound['source_files'][document] = dict(path=path, sha256=hashlib.sha256(body).hexdigest())
    bound['approval'] = dict(quote=None, values={})
    context(bound)
    return bound


@lru_cache(maxsize=1)
def release():
    body = read_bytes(ROOT / 'release.json', MAX_JSON)
    if hashlib.sha256(body).hexdigest() != RELEASE_SHA256:
        fail('RELEASE_HASH', 'release.json', 'Выпуск не совпадает с закреплённой версией заполнителя.', 'Получите проверенную версию загрузчика и скачайте комплект заново; не меняйте контрольные суммы.')
    return parse_json(body)


def pinned_bytes(relative, limit=MAX_DOCX):
    row = next((item for item in release()['files'] if item['path'] == 'agent/' + relative), None)
    if row is None:
        fail('RELEASE_PATH', relative, 'Файл отсутствует в доверенном выпуске.', 'Используйте только комплект выбранного договора.')
    body = read_bytes(ROOT / relative, limit)
    if hashlib.sha256(body).hexdigest() != row['sha256']:
        fail('RESOURCE_HASH', relative, 'Файл отличается от закреплённого выпуска.', 'Скачайте комплект заново; не исправляйте сумму ради обхода проверки.')
    return body


@lru_cache(maxsize=1)
def fields():
    return parse_json(pinned_bytes('fields.json', MAX_JSON))


def text(value, field, multiline=False):
    if not isinstance(value, str) or not value.strip():
        fail('MISSING_VALUE', field, 'Нужна непустая строка из источника.', 'Запросите это значение у пользователя; не подставляйте пример.')
    if value != value.strip():
        fail('SURROUNDING_SPACES', field, 'Есть пробелы в начале или конце значения.', 'Покажите исходное значение и уточните, можно ли убрать внешние пробелы.')
    if len(value) > (65536 if multiline else 8192):
        fail('VALUE_SIZE', field, 'Превышено ограничение длины значения.', 'Проверьте источник; не обрезайте его молча.')
    hidden = any(unicodedata.category(char).startswith('C') and char not in '\n\r\t' for char in value)
    if any(x in value for x in ('{{', '}}', '{%', '%}', '[?]')) or hidden:
        fail('PLACEHOLDER', field, 'Служебная заготовка или управляющий символ.', 'Получите значение из источника.')
    if not multiline and any(char in value for char in '\r\n\t\u2028\u2029'):
        fail('MULTILINE_VALUE', field, 'В поле реквизитов есть перенос строки или табуляция.', 'Покажите исходное значение пользователю и запросите однозначное значение для одного поля.')
    return value


def source(data, pointer, value, variants=()):
    sources = data.get('sources')
    evidence = sources.get(pointer) if isinstance(sources, dict) else None
    if not isinstance(evidence, dict) or set(evidence) != {'document', 'quote'}:
        fail('MISSING_SOURCE', pointer, 'Не указан источник значения.', 'Добавьте sources: document и дословную quote.')
    text(evidence.get('document'), pointer + ':document')
    text(evidence.get('quote'), pointer + ':quote', multiline=True)
    if not any(str(v) in evidence['quote'] for v in (value, *variants)):
        fail('SOURCE_MISMATCH', pointer, 'Значение отсутствует в сохранённой цитате.', 'Сверьте значение с источником. Преобразованную грамматическую форму отдельно подтвердите у пользователя.')


def positive(value, field):
    if type(value) is not int or not 1 <= value <= 10**9:
        fail('POSITIVE_INTEGER', field, 'Нужно положительное целое число.', 'Запросите точное количество или число месяцев.')
    return value


def money(value, field):
    if not isinstance(value, str) or len(value) > 20 or not re.fullmatch(r'[0-9]+(?:\.[0-9]{1,2})?', value):
        fail('MONEY_FORMAT', field, 'Цена — строка с точкой, не более двух знаков после неё.', 'Уточните цену; скрипт не округляет исходные значения.')
    main, _, frac = value.partition('.')
    return int(main) * 100 + int(frac.ljust(2, '0') or '0')


def rub(cents):
    whole, frac = divmod(cents, 100)
    return f'{whole:,}'.replace(',', ' ') + (f',{frac:02d}' if frac else '')


def context(data, require_approval=False, approved_sha256=None, verify_source_hashes=True):
    if not isinstance(data, dict):
        fail('DATA_OBJECT', '/', 'Нужен JSON-объект.', 'Используйте файл данных выбранного шаблона.')
    if set(data) != {'template', 'contract_date', 'licensee', 'order', 'sources', 'source_files', 'approval', 'changes'}:
        fail('DATA_FIELDS', '/', 'Набор полей JSON отличается от файла данных договора.', 'Не добавляйте команды или дополнительные поля; используйте структуру выбранного договора.')
    if require_approval and approved_sha256 != approval_digest(data):
        fail('APPROVAL_DIGEST', '--approved-sha256', 'Нет совпадающего кода из фактического ответа пользователя.', 'Получите полный ответ на текущий approval_text. Изменение данных, источников или правок требует нового подтверждения.')
    manifest = fields()
    ident = data.get('template')
    if ident not in manifest['templates']:
        fail('TEMPLATE', '/template', 'Выберите company или corporation.', 'Спросите пользователя, какой из двух договоров нужен.')
    tpl = manifest['templates'][ident]
    bound_source_files(data, verify_hashes=verify_source_hashes)
    raw_date = text(data.get('contract_date'), '/contract_date')
    try:
        date = dt.date.fromisoformat(raw_date)
        if date.isoformat() != raw_date:
            raise ValueError('Use YYYY-MM-DD')
    except ValueError:
        fail('DATE_FORMAT', '/contract_date', 'Нужна существующая дата ГГГГ-ММ-ДД.', 'Запросите дату договора; текущую дату не подставляйте.')
    source(data, '/contract_date', raw_date, (date.strftime('%d.%m.%Y'),))
    lic = data.get('licensee')
    if not isinstance(lic, dict):
        fail('LICENSEE', '/licensee', 'Нет реквизитов лицензиата.', 'Используйте данные своей организации, а не стороны Пачки.')
    ctx = {}
    extra = set(lic) - set(tpl['licensee_fields'])
    if extra:
        fail('UNKNOWN_FIELD', '/licensee', f'Поля отсутствуют в выбранном шаблоне: {sorted(extra)}.', 'Не удаляйте сведения молча: сохраните их отдельно и уточните назначение.')
    for key in tpl['licensee_fields']:
        value = text(lic.get(key), '/licensee/' + key)
        source(data, '/licensee/' + key, value)
        ctx['licensee_' + key] = value
    ctx.update(contract_number=f"Пачка-{lic['inn']}-{date:%d%m%Y}",
               contract_date_day=f'{date.day:02d}',
               contract_date_month_year=f'{MONTHS[date.month - 1]} {date.year}',
               contract_date=f'{date:%d.%m.%Y}')
    changes = data.get('changes')
    if not isinstance(changes, list):
        fail('CHANGES_LIST', '/changes', 'Нужен список изменений; [] означает, что изменений нет.', 'Перечислите каждое изменение: location, before, after, reason, user_quote.')
    for i, change in enumerate(changes):
        if not isinstance(change, dict) or set(change) != {'location', 'before', 'after', 'reason', 'user_quote'}:
            fail('CHANGE_FIELDS', f'/changes/{i}', 'Нужны location, before, after, reason, user_quote.', 'Сохраните точную правку и её основание со слов пользователя.')
        for key, value in change.items():
            text(value, f'/changes/{i}/{key}', multiline=key in ('before', 'after', 'reason', 'user_quote'))
        source(data, f'/changes/{i}/user_quote', change['user_quote'])
    if ident == 'corporation':
        order = data.get('order')
        lines = order.get('lines') if isinstance(order, dict) else None
        if isinstance(order, dict) and set(order) != {'lines'}:
            fail('ORDER_FIELDS', '/order', 'В заказе допускается только lines.', 'Сохраните дополнительные условия отдельно и уточните, как включить их в договор.')
        if not isinstance(lines, list) or not 1 <= len(lines) <= 3:
            fail('SPEC_LINES', '/order/lines', 'Для спецификации нужна одна, две или три строки.', 'Уточните строки заказа; скрипт не делит лицензии автоматически.')
        grand = 0
        for i, line in enumerate(lines, 1):
            pointer = f'/order/lines/{i-1}'
            if not isinstance(line, dict) or set(line) != {'name', 'months', 'qty', 'price'}:
                fail('SPEC_FIELDS', pointer, 'Строка содержит только name, months, qty, price.', 'Заполните все четыре поля из согласованных условий.')
            name = text(line.get('name'), pointer + '/name')
            if name == '-':
                fail('SPEC_NAME', pointer + '/name', 'Название действующей строки не может быть прочерком.', 'Неиспользуемые строки скрипт заполняет сам.')
            months = positive(line.get('months'), pointer + '/months')
            if months < 3 and not any(re.search(r'(?<!\d)4\.3(?!\d)', change['location']) for change in changes):
                fail('RULE_DEVIATION', pointer + '/months', 'Пункт 4.3 шаблона предусматривает срок не менее 3 календарных месяцев.', 'Если нужен меньший срок, запросите и явно запишите изменение пункта 4.3 в changes. Оно требует отмеченной изменённой версии и отдельной проверки.')
            qty = positive(line.get('qty'), pointer + '/qty')
            price = money(line.get('price'), pointer + '/price')
            for key, value in line.items():
                source(data, pointer + '/' + key, value)
            total = price * qty * months
            grand += total
            for key, value in dict(name=name, period=f'{months} мес.', qty=str(qty), price=rub(price), total=rub(total)).items():
                ctx[f'spec_line{i}_{key}'] = value
        for i in range(len(lines) + 1, 4):
            for key in ('name', 'period', 'qty', 'price', 'total'):
                ctx[f'spec_line{i}_{key}'] = '-'
        ctx['spec_total'] = rub(grand)
    elif data.get('order') is not None:
        fail('NO_SPEC', '/order', 'В коротком договоре «Компания» нет спецификации.', 'Сохраните условия заказа отдельно; не добавляйте их в договор без запроса на правку.')
    if require_approval:
        expected_digest = approval_digest(data)
        if not isinstance(approved_sha256, str) or not re.fullmatch('[0-9a-f]{64}', approved_sha256) or approved_sha256 != expected_digest:
            fail('APPROVAL_DIGEST', '--approved-sha256', 'Нет совпадающего кода из фактического ответа пользователя.', 'Покажите весь approval_text из --check. Передайте --approved-sha256 из возвращённого пользователем блока, а не из текущего JSON или нового --check. После изменения данных или sources получите новый ответ.')
        approval = data.get('approval')
        if not isinstance(approval, dict) or set(approval) != {'quote', 'values'}:
            fail('APPROVAL', '/approval', 'Нет подтверждения данных пользователем.', 'Покажите данные и попросите пользователя подтвердить конкретные значения.')
        quote = text(approval.get('quote'), '/approval/quote', multiline=True)
        approved = approval.get('values')
        required, _ = confirmation(data, tpl, ctx)
        if not isinstance(approved, dict) or set(approved) != set(required) or any(type(approved[k]) is not type(v) or approved[k] != v for k, v in required.items()):
            fail('APPROVAL_VALUES', '/approval/values', 'Подтверждение не соответствует текущим условиям.', 'Покажите approval_text из --check: договор, все реквизиты, номер, дату, строки заказа, итог и правки. Получите реальный ответ пользователя; после изменений повторите подтверждение.')
        if quote.replace('\r\n', '\n') != approval_text(data, tpl, ctx).replace('\r\n', '\n'):
            fail('APPROVAL_QUOTE', '/approval/quote', 'Подтверждение отличается от полного блока текущих данных и его кода.', 'Попросите пользователя вернуть весь approval_text. Сохраните реальный ответ; не создавайте его за пользователя.')
    return tpl, ctx


def template_bytes(tpl):
    if tpl not in fields()['templates'].values():
        fail('TEMPLATE', '/', 'Шаблон отсутствует в доверенной схеме.', 'Используйте company или corporation из закреплённого выпуска.')
    return pinned_bytes(tpl['file'])


def archive_parts(body):
    if len(body) > MAX_DOCX:
        raise ValueError('DOCX превышает ограничение размера.')
    with zipfile.ZipFile(io.BytesIO(body)) as archive:
        entries = archive.infolist()
        names = [entry.filename for entry in entries]
        if len(entries) > 512 or len(names) != len(set(names)):
            raise ValueError('Слишком много частей или повторяющиеся имена частей DOCX.')
        if sum(entry.file_size for entry in entries) > MAX_EXPANDED:
            raise ValueError('Превышен размер распакованного DOCX.')
        parts = {}
        for entry in entries:
            if (entry.file_size > MAX_PART or entry.flag_bits & 1 or
                    entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)):
                raise ValueError('Недопустимый размер, шифрование или сжатие части DOCX.')
            name = entry.filename
            if not name or name.startswith('/') or '\\' in name or any(piece in ('', '.', '..') for piece in name.split('/')):
                raise ValueError('Недопустимое имя части DOCX.')
            content = archive.read(entry)
            if name.endswith(('.xml', '.rels')):
                try:
                    xml = content.decode('utf-8-sig')
                except UnicodeError:
                    raise ValueError('Части XML договора должны быть в UTF-8; проверка не выполнена.') from None
                encoding = re.search(r'''^\s*<\?xml\b[^?]*\bencoding\s*=\s*["']([^"']+)''', xml, re.I)
                if '\x00' in xml or (encoding and encoding.group(1).lower() not in ('utf-8', 'utf8')):
                    raise ValueError('Части XML договора должны быть в UTF-8 без NUL; проверка не выполнена.')
                if re.search(r'<!\s*(DOCTYPE|ENTITY)', xml, re.I):
                    raise ValueError('DTD и объявления сущностей XML не допускаются.')
            parts[name] = content
        return parts


def rendered_bytes(tpl, ctx):
    snapshot = template_bytes(tpl)
    archive_parts(snapshot)
    try:
        from docxtpl import DocxTemplate
        from jinja2 import StrictUndefined
        from jinja2.sandbox import SandboxedEnvironment
    except ImportError as exc:
        fail('DEPENDENCY', 'docxtpl', str(exc), 'Создайте новое локальное venv и установите requirements.txt с --require-hashes --only-binary=:all:.')
    doc = DocxTemplate(io.BytesIO(snapshot))
    doc.render(ctx, jinja_env=SandboxedEnvironment(undefined=StrictUndefined), autoescape=True)
    result = io.BytesIO()
    doc.save(result)
    return result.getvalue()


def xml_equal(actual, expected):
    # Compare the entire XML document, including root attributes and relationships.
    return ET.canonicalize(actual.decode('utf-8')) == ET.canonicalize(expected.decode('utf-8'))


GUARD_RUNNER = r"""
import io, json, sys, zipfile
import xml.etree.ElementTree as ET
scope = {'__name__': 'verified_contract_guard'}
exec(compile(sys.argv[1], 'verified_check_template_artifacts.py', 'exec'), scope)
with zipfile.ZipFile(io.BytesIO(sys.stdin.buffer.read())) as archive:
    root = ET.fromstring(archive.read('word/document.xml'))
issues = []
for name in ('check_highlights', 'check_placeholders', 'check_font_consistency', 'check_empty_sdt'):
    issues.extend(scope[name](root))
report = {'file': sys.argv[2], 'total_issues': len(issues), 'issues': issues}
print(json.dumps(report, ensure_ascii=False, indent=2))
if not issues:
    print('\n✓ Артефактов шаблона не найдено', file=sys.stderr)
else:
    sys.exit(1)
"""


def verify_bytes(snapshot, tpl, ctx, filename):
    try:
        actual = archive_parts(snapshot)
        expected = archive_parts(rendered_bytes(tpl, ctx))
        if set(actual) != set(expected):
            fail('DOCX_PARTS', filename, 'Состав частей DOCX отличается от ожидаемого заполнения.', 'Проверьте добавленные, удалённые части и вложения. Не отключайте сверку.')
        for name, content in actual.items():
            equal = xml_equal(content, expected[name]) if name.endswith(('.xml', '.rels')) else content == expected[name]
            if not equal:
                fail('CONTENT_MISMATCH', name, 'Часть документа отличается от ожидаемого заполнения шаблона.', 'Повторите заполнение из подтверждённых данных. Запрошенные правки текста проверяйте отдельно как изменённый документ.')
            if name.startswith('word/') and name.endswith('.xml'):
                root = ET.fromstring(content)
                visible = ''.join(node.text or '' for node in root.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'))
                if any(tag in visible for tag in ('{{', '}}', '{%', '%}', '[?]')):
                    fail('UNFILLED_TEMPLATE', name, 'Остались переменные шаблона.', 'Проверьте данные и повторите заполнение.')
    except (OSError, KeyError, ValueError, ET.ParseError, zipfile.BadZipFile, RuntimeError) as exc:
        fail('DOCX_READ', filename, str(exc), 'Проверьте DOCX, его размер и состав; HTML, неоднозначные ZIP и XML-сущности не допускаются.')
    return dict(content_matches=True, **artifact_check(snapshot, filename))


def artifact_check(snapshot, filename):
    # Bound ZIP expansion and reject duplicate parts/entities for edited files too.
    try:
        archive_parts(snapshot)
    except (ValueError, zipfile.BadZipFile, RuntimeError) as exc:
        fail('DOCX_READ', filename, str(exc), 'Проверьте размер и структуру DOCX; не отключайте ограничения.')
    # Execute hash-verified scanner source on these exact bytes, never reopen a
    # mutable script or DOCX between validation, scanning and hashing.
    guard = pinned_bytes('scripts/check_template_artifacts.py', MAX_JSON)
    run = subprocess.run([sys.executable, '-I', '-X', 'utf8', '-c', GUARD_RUNNER, guard.decode('utf-8'), Path(filename).name],
                         input=snapshot, capture_output=True, timeout=30)
    stdout, stderr = run.stdout.decode('utf-8'), run.stderr.decode('utf-8')
    try:
        report = parse_json(stdout)
    except ValueError:
        fail('CHECKER_OUTPUT', 'scripts/check_template_artifacts.py', stdout + stderr, 'Проверка не завершилась; сохраните исходное сообщение.')
    if run.returncode != 0 or report.get('total_issues') != 0 or '✓ Артефактов шаблона не найдено' not in stderr:
        fail('ARTIFACTS', filename, stdout + stderr, 'Исправьте данные; не выдавайте документ за проверенный.')
    return dict(artifact_check=report, checker_exit_code=run.returncode,
                checker_stdout=stdout, checker_stderr=stderr,
                sha256=hashlib.sha256(snapshot).hexdigest(), snapshot_verified=True)


def verify(path, tpl, ctx):
    return verify_bytes(read_bytes(path, MAX_DOCX), tpl, ctx, str(path))


def publish(out, body, suffix='.docx'):
    # Exclusive creation is the filesystem operation, not a prior exists() test.
    out = Path(os.path.abspath(out.expanduser()))
    if out.suffix.lower() != suffix or not out.parent.is_dir():
        fail('OUTPUT_PATH', str(out), 'Нужна существующая папка и имя ' + suffix + '.', 'Уточните у пользователя папку; не выбирайте её молча.')
    parent = out.parent.resolve(strict=True)
    out = parent / out.name
    flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0)
    parent_fd = None
    try:
        if os.open in os.supports_dir_fd:
            parent_fd = os.open(parent, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0))
            fd = os.open(out.name, flags, 0o600, dir_fd=parent_fd)
        else:
            fd = os.open(out, flags, 0o600)
        with os.fdopen(fd, 'wb') as handle:
            handle.write(body)
            handle.flush()
            os.fsync(handle.fileno())
            written = os.fstat(handle.fileno())
            current = out.stat(follow_symlinks=False)
            if (written.st_dev, written.st_ino, written.st_size) != (current.st_dev, current.st_ino, current.st_size):
                fail('OUTPUT_CHANGED', str(out), 'Путь файла изменился во время сохранения.', 'Остановитесь и проверьте папку; не передавайте файл как готовый.')
    except FileExistsError:
        fail('OUTPUT_EXISTS', str(out), 'Файл или символическая ссылка уже существует.', 'Выберите другое имя; скрипт не перезаписывает файлы.')
    except OSError as exc:
        fail('OUTPUT_WRITE', str(out), str(exc), 'Проверьте папку и права; результат не считается сохранённым и проверенным.')
    finally:
        if parent_fd is not None:
            os.close(parent_fd)
    return out


def description(template, language):
    catalog = parse_json(pinned_bytes('summaries.json', MAX_JSON))
    item = catalog['templates'][template]
    if item['template_sha256'] != fields()['templates'][template]['sha256']:
        fail('DESCRIPTION_TEMPLATE', template, 'Описание относится к другой версии шаблона.', 'Получите согласованный комплект; не пересказывайте описание как описание изменённого договора.')
    return dict(text=item[language], scope=catalog['scope'][language],
                source_template_sha256=item['template_sha256'])


def pending_document_review():
    """Technical success cannot certify rendering or independent review."""
    return dict(status='draft', ready_for_delivery=False, user_review_required=True,
                review_requirements={
                    'sources_fields_and_changes': dict(status='not_performed_by_script', scope='entire_document'),
                    'word_numbering_and_layout': dict(status='not_performed_by_script', scope='entire_document',
                        compare_with='verified_template_and_confirmed_changes'),
                    'pdf_numbering_and_layout': dict(status='not_performed_by_script', scope='entire_document',
                        required_if='pdf_requested', compare_with='reviewed_word_document'),
                    'independent_review': dict(status='not_performed_by_script', scope='entire_document')})


def save_snapshot(path, data, data_body, report):
    source_checks = bound_source_files(data)  # Refuse a source swapped during filling/checking.
    record = dict(format=1, kind='contract_verification_snapshot',
                  captured_at=dt.datetime.now(dt.timezone.utc).isoformat(),
                  data=data, data_file_sha256=hashlib.sha256(data_body).hexdigest(),
                  approved_sha256=approval_digest(data), release_sha256=RELEASE_SHA256,
                  document=dict(path=report['file'], sha256=report['sha256']),
                  checks=report, source_checks=source_checks,
                  source_authenticity_verified=False, user_review_required=True)
    body = encoded(record)
    if len(body) > MAX_SNAPSHOT:
        fail('SNAPSHOT_SIZE', str(path), 'Превышен размер снимка.', 'Сохраните полный отчёт и исходные данные; не сокращайте их молча.')
    destination = publish(path, body, suffix='.json')
    return dict(file=str(destination), sha256=hashlib.sha256(body).hexdigest(),
                note='Сохраните этот SHA-256 отдельно от снимка, например в полученном сообщении агента. Подлинность первоначальных источников и ответов человека этим не установлена.')


def data_diff(before, after, pointer=''):
    if not isinstance(before, (dict, list)) and type(before) is type(after) and before == after:
        return []
    if isinstance(before, dict) and isinstance(after, dict):
        changes = []
        for key in sorted(set(before) | set(after)):
            path = pointer + '/' + key.replace('~', '~0').replace('/', '~1')
            if key not in before or key not in after:
                changes.append(dict(field=path, before=before.get(key), after=after.get(key),
                                    before_missing=key not in before, after_missing=key not in after))
            else:
                changes.extend(data_diff(before[key], after[key], path))
        return changes
    if isinstance(before, list) and isinstance(after, list):
        changes = []
        for i in range(max(len(before), len(after))):
            path = pointer + '/' + str(i)
            if i >= len(before) or i >= len(after):
                changes.append(dict(field=path, before=before[i] if i < len(before) else None,
                                    after=after[i] if i < len(after) else None,
                                    before_missing=i >= len(before), after_missing=i >= len(after)))
            else:
                changes.extend(data_diff(before[i], after[i], path))
        return changes
    return [dict(field=pointer or '/', before=before, after=after)]


def observed_file(path, expected_sha256):
    try:
        body = read_bytes(path, MAX_DOCX)
        digest = hashlib.sha256(body).hexdigest()
        return dict(path=path, expected_sha256=expected_sha256, actual_sha256=digest,
                    unchanged=digest == expected_sha256)
    except Failure as exc:
        return dict(path=path, expected_sha256=expected_sha256, actual_sha256=None,
                    unchanged=False, error=exc.problem)


def compare_snapshot(path, digest, data, data_body, document_path=None, source_paths=None):
    if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
        fail('SNAPSHOT_ANCHOR', '--snapshot-sha256', 'Нужен отпечаток из отдельно сохранённого отчёта.', 'Возьмите его из фактического сообщения или защищённой копии отчёта; не вычисляйте заново из сравниваемого снимка.')
    body = read_bytes(path, MAX_SNAPSHOT)
    if hashlib.sha256(body).hexdigest() != digest:
        fail('SNAPSHOT_HASH', str(path), 'Снимок отличается от ранее сохранённого отпечатка.', 'Сохраните оба файла и выясните подмену. Не подставляйте новый SHA-256 ради успешной проверки.')
    baseline = parse_json(body)
    if not isinstance(baseline, dict) or baseline.get('format') != 1 or baseline.get('kind') != 'contract_verification_snapshot':
        fail('SNAPSHOT_FORMAT', str(path), 'Нужен снимок проверки договора.', 'Используйте снимок, сохранённый через --snapshot.')
    context(baseline['data'], require_approval=True, approved_sha256=baseline['approved_sha256'], verify_source_hashes=False)
    changes = data_diff(baseline['data'], data)
    original_sources = baseline['data']['source_files']
    current_sources = data.get('source_files') if isinstance(data, dict) else None
    if not isinstance(current_sources, dict) or len(current_sources) > MAX_SOURCES:
        fail('SOURCE_FILES', '/source_files', 'Нужна карта файлов источников.', 'Сохраните изменения данных; восстановите структуру source_files для сверки.')
    if source_paths is not None and (not isinstance(source_paths, dict) or set(source_paths) != set(current_sources)):
        fail('SOURCE_MAP', '--compare-sources', 'Карта должна содержать ровно источники текущего JSON.', 'Укажите реальные новые пути оригиналов по document. Снимок и подтверждённые данные не изменяйте ради переноса.')
    sources = []
    for document in sorted(set(original_sources) | set(current_sources)):
        old = original_sources.get(document)
        current = current_sources.get(document)
        if current is None:
            sources.append(dict(document=document, unchanged=False, removed=True))
            continue
        if not isinstance(current, dict) or set(current) != {'path', 'sha256'}:
            fail('SOURCE_FILE_FIELDS', document, 'Нужны path и sha256.', 'Сохраните изменённые данные; не удаляйте источники ради совпадения.')
        path_value = absolute_path(text(source_paths[document], document + ':path')) if source_paths is not None else text(current['path'], document + ':path')
        if not Path(path_value).is_absolute():
            fail('SOURCE_FILE_PATH', document, 'Нужен абсолютный путь источника.', 'Укажите фактическое место сохранения оригинала.')
        item = observed_file(path_value, old['sha256'] if old else None)
        item.update(document=document, declared_sha256=current['sha256'],
                    declared_matches_file=item['actual_sha256'] == current['sha256'])
        item['unchanged'] = item['unchanged'] and item['declared_matches_file']
        sources.append(item)
    document = baseline['document']
    contract = observed_file(absolute_path(document_path) if document_path else document['path'], document['sha256'])
    data_file_changed = baseline['data_file_sha256'] != hashlib.sha256(data_body).hexdigest()
    release_changed = baseline['release_sha256'] != RELEASE_SHA256
    unchanged = not changes and not data_file_changed and not release_changed and contract['unchanged'] and all(item['unchanged'] for item in sources)
    return dict(ok=True, mode='snapshot_comparison', unchanged=unchanged,
                data_changes=changes, data_file_changed=data_file_changed,
                sources=sources, document=contract, release_changed=release_changed,
                baseline_sha256=digest, **pending_document_review(),
                note='Сверены байты и значения с сохранённым снимком. Подлинность источников и авторство подтверждения не устанавливаются; изменённый текст требует отдельной сверки.')


def emit_check_report(report, snapshot_path, data, data_body):
    if snapshot_path:
        try:
            report['saved_snapshot'] = save_snapshot(snapshot_path, data, data_body, report)
        except Failure as exc:
            exc.problem.update(existing_document=report['file'], document_sha256=report['sha256'],
                               snapshot_saved=False, document_created_by_this_command=False)
            raise
    print(json.dumps(report, ensure_ascii=False, indent=2))


def main():
    ap = argparse.ArgumentParser(description='Заполнить или проверить договор из двух шаблонов Пачки')
    ap.add_argument('data', help='JSON с данными, источниками и подтверждением')
    mode = ap.add_mutually_exclusive_group(required=True)
    mode.add_argument('--check', action='store_true')
    mode.add_argument('--out', type=Path)
    mode.add_argument('--verify', type=Path)
    mode.add_argument('--artifacts', type=Path, help='только артефакты изменённого договора; содержание и правки отдельно проверяет человек')
    mode.add_argument('--bind-sources', type=Path, help='JSON-карта document → путь оригинала; вычислить отпечатки и сбросить прежнее подтверждение')
    mode.add_argument('--compare-snapshot', type=Path, help='сверить данные, оригиналы и Word с ранее сохранённым снимком')
    ap.add_argument('--approved-sha256', help='код из фактического полного подтверждения пользователя; отдельно от изменяемого JSON')
    ap.add_argument('--base-for-changes', action='store_true', help='создать только заполненный черновик-основу, текстовые правки ещё не применены')
    ap.add_argument('--bound-data', type=Path, help='новый JSON для результата --bind-sources; прежние файлы не перезаписываются')
    ap.add_argument('--snapshot', type=Path, help='сохранить новый JSON-снимок вместе с --verify или --artifacts')
    ap.add_argument('--snapshot-sha256', help='ранее сохранённый внешний SHA-256 снимка для --compare-snapshot')
    ap.add_argument('--compare-document', type=Path, help='фактический новый путь Word при --compare-snapshot, если файл перемещён')
    ap.add_argument('--compare-sources', type=Path, help='JSON-карта document → новый путь оригинала только для сравнения перенесённого комплекта')
    ap.add_argument('--language', choices=('ru', 'en'), default='ru', help='язык готового описания исходного шаблона')
    args = ap.parse_args()
    if bool(args.bind_sources) != bool(args.bound_data):
        fail('BIND_MODE', '--bound-data', 'Нужны вместе --bind-sources и --bound-data.', 'Укажите карту реальных файлов и новое имя JSON, затем получите новое подтверждение.')
    if args.snapshot and not (args.verify or args.artifacts):
        fail('SNAPSHOT_MODE', '--snapshot', 'Снимок сохраняется только при --verify или --artifacts.', 'Сначала создайте договор, затем отдельным запуском проверьте его и сохраните снимок.')
    if (args.snapshot_sha256 or args.compare_document or args.compare_sources) and not args.compare_snapshot:
        fail('COMPARE_MODE', '--compare-snapshot', 'Аргументы снимка относятся только к сравнению.', 'Укажите --compare-snapshot и прежний внешний --snapshot-sha256.')
    if (args.bind_sources or args.compare_snapshot) and (args.approved_sha256 or args.base_for_changes):
        fail('ARGUMENT_MODE', '/', 'Привязка и сравнение не используют эти параметры заполнения.', 'Не смешивайте привязку, подтверждение и проверку договора; выполните шаги отдельно.')
    data_body = read_bytes(args.data, MAX_JSON)
    try:
        data = parse_json(data_body)
    except (ValueError, RecursionError) as exc:
        fail('READ_JSON', str(args.data), str(exc), 'Проверьте исходный JSON; не выдавайте ошибку за успешную проверку.')
    if args.bind_sources:
        bound = bind_sources(data, read_json(args.bind_sources))
        destination = publish(args.bound_data, encoded(bound), suffix='.json')
        print(json.dumps(dict(ok=True, mode='sources_bound', file=str(destination), source_files=bound['source_files'],
                              approval_reset=True, user_review_required=True, source_authenticity_verified=False,
                              note='Отпечатки вычислены из реальных файлов. Проверьте каждую цитату по оригиналу. Прежнее подтверждение сброшено; запустите --check и получите новый фактический ответ.'), ensure_ascii=False, indent=2))
        return
    if args.compare_snapshot:
        report = compare_snapshot(args.compare_snapshot, args.snapshot_sha256, data, data_body, args.compare_document,
                                  read_json(args.compare_sources) if args.compare_sources else None)
        print(json.dumps(report, ensure_ascii=False, indent=2))
        if not report['unchanged']:
            sys.exit(1)
        return
    tpl, ctx = context(data, require_approval=not args.check, approved_sha256=args.approved_sha256)
    template_bytes(tpl)
    if args.check:
        values, _ = confirmation(data, tpl, ctx)
        print(json.dumps(dict(ok=True, mode='preview', fields=ctx, changes=data['changes'], approval_values=values,
                              approval_text=approval_text(data, tpl, ctx), approval_sha256=approval_digest(data),
                              contract_description=description(data['template'], args.language),
                              source_checks=bound_source_files(data), source_authenticity_verified=False,
                              note='Файл не создан; источники и подтверждения дополнительно сверяет агент. approval_text — предложение подтверждения, а не ответ пользователя.'), ensure_ascii=False, indent=2))
        return
    if args.base_for_changes and (not args.out or not data['changes']):
        fail('BASE_MODE', '--base-for-changes', 'Режим основы требует --out и непустого changes.', 'Для стандартного договора уберите этот флаг. Для правок сохраните их в changes.')
    if args.artifacts:
        snapshot = read_bytes(args.artifacts, MAX_DOCX)
        checks = artifact_check(snapshot, str(args.artifacts))
        report = dict(ok=True, mode='artifacts_only', file=absolute_path(args.artifacts), **checks,
                              content_matches=False, text_changes_verified=False, **pending_document_review(),
                              approved_sha256=args.approved_sha256, release_sha256=RELEASE_SHA256,
                              note='Проверены только артефакты и структура ZIP. Реквизиты, содержание, правки и источники требуют независимой сверки; статус — черновик.')
        emit_check_report(report, args.snapshot, data, data_body)
        return
    if data['changes'] and not args.base_for_changes:
        fail('TEXT_CHANGES', '/changes', 'Запрошены правки текста. Стандартный заполнитель их не применяет.', 'Сохраните изменения в отдельном протоколе. Подготовьте изменённый документ средствами редактора с отметками; отдельно проверьте его. Не представляйте стандартное заполнение как выполненные правки.')
    if args.verify:
        checks = verify(args.verify, tpl, ctx)
        report = dict(ok=True, mode='verified_standard', file=absolute_path(args.verify), **checks, **pending_document_review(),
                      approved_sha256=args.approved_sha256, release_sha256=RELEASE_SHA256)
        emit_check_report(report, args.snapshot, data, data_body)
        return
    out = args.out.expanduser()
    if args.base_for_changes and 'черновик' not in out.stem.casefold():
        fail('DRAFT_NAME', str(out), 'Название основы должно содержать «черновик».', 'Используйте имя «Базовый_черновик.docx»: правки текста ещё не применены.')
    snapshot = rendered_bytes(tpl, ctx)
    checks = verify_bytes(snapshot, tpl, ctx, str(out))
    out = publish(out, snapshot)
    print(json.dumps(dict(ok=True, mode='base_changes_pending' if args.base_for_changes else 'filled_standard', file=str(out), **checks,
                          approved_sha256=args.approved_sha256, release_sha256=RELEASE_SHA256,
                          **pending_document_review(), text_changes_applied=False, changes_pending=data['changes'],
                          note='Черновик-основа: текстовые изменения и отметки ещё не внесены; готовым договором не является.' if args.base_for_changes else 'Техническая проверка завершена; независимая сверка и просмотр выполняются отдельно.'), ensure_ascii=False, indent=2))


if __name__ == '__main__':
    try:
        main()
    except Failure as exc:
        print(json.dumps(dict(ok=False, errors=[exc.problem]), ensure_ascii=False, indent=2))
        sys.exit(1)
    except Exception as exc:
        print(json.dumps(dict(ok=False, errors=[dict(code='UNEXPECTED', message=str(exc), next_step='Сохраните дословную ошибку и шаг. Не выдавайте результат за готовый договор.')]), ensure_ascii=False, indent=2))
        sys.exit(2)
