# -*- coding: utf-8 -*-
"""DokoGen — Генератор документов из шаблонов DOCX (чистая версия)"""

import os
import re
import io
import shutil
import tempfile
import zipfile
from typing import Dict, List, Optional, Callable, Any
from datetime import datetime
from functools import lru_cache

try:
    from docx import Document
    from docx.oxml.ns import qn
    from docx.oxml import OxmlElement
    from lxml import etree
    DOCX_AVAILABLE = True
except ImportError:
    Document = None
    DOCX_AVAILABLE = False

# ============================================================
# 1. ПЕРЕМЕННЫЕ ШАБЛОНОВ И РЕГЕКСЫ
# ============================================================

# Паттерны для поиска переменных и маркеров циклов
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>')

# «Переменные», которые на самом деле не переменные, а клавиши в тексте
# инструкций (Ctrl+Alt+Del и т.п.) — их не считаем незаменёнными
IGNORED_OLD_VARS = {
    '<Ctrl>', '<Alt>', '<Del>', '<Delete>', '<Shift>', '<Enter>', '<Tab>',
    '<Esc>', '<Escape>', '<Insert>', '<Home>', '<End>', '<PageUp>', '<PageDown>',
    '<Backspace>', '<Space>', '<CapsLock>', '<NumLock>', '<ScrollLock>',
    '<PrintScreen>', '<Pause>', '<Break>', '<Up>', '<Down>', '<Left>', '<Right>',
    '<F1>', '<F2>', '<F3>', '<F4>', '<F5>', '<F6>', '<F7>', '<F8>', '<F9>',
    '<F10>', '<F11>', '<F12>',
}

LOOP_START = re.compile(r'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)')

# Маппинг старых переменных <...> → {{...}}
OLD_VAR_MAP = {
    '<CompanyName1>': '{{company_full_name}}',
    '<CompanyName2>': '{{company_short_name}}',
    '<CompanyName1Gent>': '{{company_full_name_genitive}}',
    '<CompanyName2Gent>': '{{company_short_name_genitive}}',
    '<CompanyName1Datv>': '{{company_full_name_dative}}',
    '<CompanyName2Datv>': '{{company_short_name_dative}}',
    '<CompanyName1Accs>': '{{company_full_name_accs}}',
    '<CompanyName2Accs>': '{{company_short_name_accs}}',
    '<CompanyName1Ablt>': '{{company_full_name_ablt}}',
    '<CompanyName2Ablt>': '{{company_short_name_ablt}}',
    '<CompanyName1Loct>': '{{company_full_name_loct}}',
    '<CompanyName2Loct>': '{{company_short_name_loct}}',
    '<Address>': '{{company_address}}',
    '<INN>': '{{company_inn}}',
    '<OGRN>': '{{company_ogrn}}',
    '<KPP>': '{{company_kpp}}',
    '<ChiefFio>': '{{chief_fio_initials}}',
    '<ChiefFioShort>': '{{chief_fio_short}}',
    '<ChiefPosition>': '{{chief_position}}',
    '<RspFio>': '{{responsible_fio_initials}}',
    '<RspFioShort>': '{{responsible_fio_short}}',
    '<RspPosition>': '{{responsible_position}}',
    '<CommissionList>': '{{commission_list}}',
    '<DocumentNumber>': '{{document_number}}',
    '<DocumentDate>': '{{document_date}}',
    '<Date>': '{{date}}',
    # Краткое наименование по падежам (старые шаблоны)
    '<ShortCompanyName1>': '{{company_short_name}}',
    '<ShortCompanyName2>': '{{company_short_name_genitive}}',
    '<ShortCompanyName3>': '{{company_short_name_dative}}',
    '<ShortCompanyName4>': '{{company_short_name_accs}}',
    '<ShortCompanyName5>': '{{company_short_name_ablt}}',
    '<ShortCompanyName6>': '{{company_short_name_loct}}',
    '<IspdnName>': '{{is_name_1}}',
}


# ============================================================
# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML
# ============================================================

W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'


def _normalize_loop_markers(doc_xml: str) -> str:
    """Word разбивает маркеры <!-- loop:... --> и переменные {{item.x}}
    на несколько run'ов (например '<!-- ' в одном <w:t>, 'loop:info' в другом,
    'rmation -->' в третьем). Склеиваем текст каждого параграфа в один run,
    чтобы маркеры и переменные циклов снова стали едиными."""
    try:
        root = etree.fromstring(doc_xml.encode('utf-8'))
    except Exception:
        return doc_xml

    changed = False
    for p in root.iter(f'{{{W_NS}}}p'):
        # Собираем весь текст параграфа (по всем run'ам),
        # табы <w:tab/> превращаем в символ \t, чтобы не потерять
        texts = []
        for child in p.iter():
            tag = child.tag.split('}')[-1]
            if tag == 't':
                texts.append(child.text or '')
            elif tag == 'tab':
                texts.append('\t')
        full = ''.join(texts)

        # Интересуют параграфы с маркерами циклов ИЛИ любыми переменными {{...}}
        if '{{' not in full and 'loop:' not in full and 'loop_end' not in full:
            continue

        # Был ли в параграфе разрыв страницы (Word хранит его отдельным run)
        has_page_break = any(
            br.get(f'{{{W_NS}}}type') == 'page'
            for br in p.iter(f'{{{W_NS}}}br')
        )

        # Сохраняем форматирование первого run
        first_r = p.find(f'{{{W_NS}}}r')
        rpr = None
        if first_r is not None:
            rpr_el = first_r.find(f'{{{W_NS}}}rPr')
            if rpr_el is not None:
                rpr = etree.fromstring(etree.tostring(rpr_el))

        # Оставляем только pPr, удаляем все run'ы
        for child in list(p):
            if child.tag != f'{{{W_NS}}}pPr':
                p.remove(child)

        # Разрыв страницы — отдельным run ПЕРЕД текстом (как было в оригинале)
        if has_page_break:
            br_r = etree.SubElement(p, f'{{{W_NS}}}r')
            if rpr is not None:
                br_r.append(etree.fromstring(etree.tostring(rpr)))
            br_el = etree.SubElement(br_r, f'{{{W_NS}}}br')
            br_el.set(f'{{{W_NS}}}type', 'page')

        # Создаём run с полным текстом параграфа, восстанавливая табы <w:tab/>
        new_r = etree.SubElement(p, f'{{{W_NS}}}r')
        if rpr is not None:
            new_r.append(rpr)
        # Разбиваем по \t: обычный текст → <w:t>, таб → <w:tab/>
        parts = full.split('\t')
        for i, part in enumerate(parts):
            if part:
                new_t = etree.SubElement(new_r, f'{{{W_NS}}}t')
                new_t.text = part
                new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
            if i < len(parts) - 1:
                tab_el = etree.SubElement(new_r, f'{{{W_NS}}}tab')
        changed = True

    if not changed:
        return doc_xml

    return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')


def _strip_edge_empty_paras(fragment: str) -> str:
    """Удаляет пустые параграфы (без текста) с начала и конца XML-фрагмента.
    Word оставляет их вокруг маркеров <!-- loop:... -->, из-за чего
    при размножении цикла между строками появляются пустые строки."""
    para_re = re.compile(r'<w:p\b[^>]*?(?:/>|>.*?</w:p>)', re.S)

    def _is_empty(p: str) -> bool:
        # Не пустой, если есть разрыв страницы, рисунок, таблица и т.п.
        if '<w:br' in p or '<w:drawing' in p or '<w:tbl' in p or '<w:object' in p:
            return False
        texts = re.findall(r'<w:t[^>]*>(.*?)</w:t>', p, re.S)
        if not texts:
            return True
        return all(t.strip() == '' for t in texts)

    # С начала
    while True:
        m = para_re.match(fragment)
        if not m:
            break
        if _is_empty(m.group(0)):
            fragment = fragment[m.end():]
        else:
            break

    # С конца
    while True:
        m = para_re.search(fragment)
        if not m:
            break
        if m.end() != len(fragment):
            break
        if _is_empty(m.group(0)):
            fragment = fragment[:m.start()]
        else:
            break

    return fragment


def _top_level_ranges(fragment: str):
    """Возвращает список (start, end) ПОЛНЫХ элементов верхнего уровня
    (w:p, w:tbl) в XML-фрагменте. Таблицы считаются одним элементом.
    Обрывки (незакрытые <w:p в конце фрагмента) не включаются —
    они относятся к параграфу маркера конца (suffix)."""
    ranges = []
    i = 0
    n = len(fragment)
    while i < n:
        if fragment.startswith('<w:tbl>', i) or fragment.startswith('<w:tbl ', i):
            j = i
            d = 0
            while j < n:
                if fragment.startswith('<w:tbl>', j) or fragment.startswith('<w:tbl ', j):
                    d += 1
                    j += 5
                elif fragment.startswith('</w:tbl>', j):
                    d -= 1
                    j += 8
                    if d == 0:
                        break
                else:
                    j += 1
            ranges.append((i, j))
            i = j
        elif fragment.startswith('<w:p>', i) or fragment.startswith('<w:p ', i):
            j = fragment.find('</w:p>', i)
            if j == -1:
                break  # обрывок — не полный параграф, дальше suffix
            j += 6
            ranges.append((i, j))
            i = j
        else:
            i += 1
    return ranges


def _split_template_edges(template: str):
    """Разделяет шаблон цикла на три части:
    prefix — хвост параграфа маркера начала (после -->),
    core — полные элементы верхнего уровня (параграфы/таблицы) между маркерами,
    suffix — начало параграфа маркера конца (до <!--)."""
    ranges = _top_level_ranges(template)
    if not ranges:
        return '', template, ''
    prefix = template[:ranges[0][0]]
    suffix = template[ranges[-1][1]:]
    core = template[ranges[0][0]:ranges[-1][1]]
    return prefix, core, suffix


def _cleanup_marker_paras(doc_xml: str) -> str:
    """Удаляет параграфы-остатки маркеров циклов: <w:p>...<w:r><w:t></w:t></w:r></w:p>
    (пустой run — бывший маркер <!-- loop:... -->). Намеренные пустые строки
    (<w:p/> без run'ов) не трогаем."""
    try:
        root = etree.fromstring(doc_xml.encode('utf-8'))
    except Exception:
        return doc_xml
    removed = False
    for p in list(root.iter(f'{{{W_NS}}}p')):
        # Не трогаем параграфы внутри таблиц (пустые ячейки валидны)
        parent = p.getparent()
        if parent is None:
            continue
        if parent.tag == f'{{{W_NS}}}tc':
            continue
        # Не трогаем параграфы с разрывом страницы/строки, рисунками и т.п.
        if (p.find(f'{{{W_NS}}}r/{{{W_NS}}}br') is not None
                or p.find(f'{{{W_NS}}}r/{{{W_NS}}}drawing') is not None
                or p.find(f'{{{W_NS}}}r/{{{W_NS}}}object') is not None
                or p.find(f'{{{W_NS}}}r/{{{W_NS}}}pict') is not None):
            continue
        # Есть непустой текст — не трогаем
        texts = [t.text or '' for t in p.iter(f'{{{W_NS}}}t')]
        if any(t.strip() for t in texts):
            continue
        # Есть run'ы (признак бывшего маркера), но текста нет — удаляем
        runs = p.findall(f'{{{W_NS}}}r')
        if not runs:
            continue
        parent.remove(p)
        removed = True
    if not removed:
        return doc_xml
    return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')


def _subst_var_xml(xml: str, var: str, val: str) -> str:
    """Замена {{var}} в XML-фрагменте.

    Пустые значения («—»/«») подсвечиваются жёлтым маркером: значение
    выносится в отдельный run с <w:highlight w:val="yellow"/>, чтобы
    подсветка не затиралась при подстановке на уровне XML (циклы).
    """
    token = '{{%s}}' % var
    if val not in ('—', ''):
        return xml.replace(token, val)
    hl_run = ('<w:rPr><w:highlight w:val="yellow"/></w:rPr>'
              '<w:t xml:space="preserve">%s</w:t>' % val)
    return xml.replace(token,
                       '</w:t></w:r><w:r>' + hl_run
                       + '</w:r><w:r><w:t xml:space="preserve">')


def _expand_nested_loops(template: str, item: Dict) -> str:
    """Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
    данными из item (список словарей). Используется для циклов внутри цикла ИС,
    например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}."""
    result = template
    for _ in range(20):
        m_start = LOOP_START.search(result)
        if not m_start:
            break
        key = m_start.group(1)
        data = item.get(key)
        if not isinstance(data, list):
            # Нет данных для вложенного цикла — удаляем блок целиком
            depth = 1
            pos = m_start.end()
            m_end = None
            for mm in LOOP_END.finditer(result, pos):
                depth -= 1
                if depth == 0:
                    m_end = mm
                    break
            if m_end:
                result = result[:m_start.start()] + result[m_end.end():]
            continue
        # Ищем соответствующий loop_end с учётом вложенности
        depth = 1
        pos = m_start.end()
        m_end = None
        markers = sorted(
            [('s', x) for x in LOOP_START.finditer(result, pos)] +
            [('e', x) for x in LOOP_END.finditer(result, pos)],
            key=lambda t: t[1].start()
        )
        for kind, mm in markers:
            if kind == 's':
                depth += 1
            else:
                depth -= 1
                if depth == 0:
                    m_end = mm
                    break
        if m_end is None:
            break
        inner = result[m_start.end():m_end.start()]
        parts = []
        for sub in data:
            clone = inner
            for f, v in sub.items():
                val = str(v) if v else ''
                clone = _subst_var_xml(clone, 'user_%s' % f, val)
                clone = _subst_var_xml(clone, 'item.%s' % f, val)
            parts.append(clone)
        result = result[:m_start.start()] + ''.join(parts) + result[m_end.end():]
    return result


def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
    """Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
    Работает до загрузки python-docx — гарантирует корректную вложенность.
    
    Args:
        docx_path: путь к шаблону .docx
        loops: словарь {имя_цикла: [{field: value}, ...]}
    
    Returns:
        путь к обработанному .docx (временный файл)
    """
    if not loops:
        return docx_path

    # Временный файл
    tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
    tmp.close()

    wrote = False  # была ли хоть одна запись в tmp
    max_passes = 30
    for _ in range(max_passes):
        # Читаем ВСЕ файлы из исходного ZIP
        all_files = {}
        try:
            with zipfile.ZipFile(docx_path if _ == 0 else tmp.name, 'r') as z:
                for name in z.namelist():
                    all_files[name] = z.read(name)
                doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
        except Exception:
            break

        if not doc_xml:
            break

        # Word мог разбить маркеры циклов на несколько run'ов — склеиваем
        doc_xml = _normalize_loop_markers(doc_xml)

        # Находим ВСЕ маркеры циклов
        start_markers = list(LOOP_START.finditer(doc_xml))
        end_markers = list(LOOP_END.finditer(doc_xml))

        if not start_markers:
            break  # нет больше циклов

        # Внешний цикл = первый start (самый ранний в документе)
        outer = start_markers[0]
        outer_key = outer.group(1)

        # Соответствующий end = первый end после outer (или последний, если вложенные)
        # Считаем глубину: каждый start +1, каждый end -1
        depth = 1
        outer_end = None
        all_markers = sorted(
            [('start', m) for m in start_markers] +
            [('end', m) for m in end_markers],
            key=lambda x: x[1].start()
        )
        found_start = False
        for kind, m in all_markers:
            if m.start() <= outer.start():
                continue
            if kind == 'start':
                depth += 1
            elif kind == 'end':
                depth -= 1
                if depth == 0:
                    outer_end = m
                    break

        if outer_end is None:
            break

        items = loops.get(outer_key, [])
        if not items:
            # Удаляем блок цикла полностью
            doc_xml = doc_xml[:outer.start()] + doc_xml[outer_end.end():]
            all_files['word/document.xml'] = doc_xml.encode('utf-8')
            with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
                for name, data in all_files.items():
                    z.writestr(name, data)
            wrote = True
            continue

        # Извлекаем шаблон (всё между start и end маркерами)
        template = doc_xml[outer.end():outer_end.start()]

        # Word оставляет обрывки параграфов вокруг маркеров и пустые параграфы.
        # Отделяем обрывки (prefix/suffix) от тела цикла и чистим пустые
        # параграфы в теле — иначе между итерациями появляются пустые строки.
        prefix, core, suffix = _split_template_edges(template)
        core = _strip_edge_empty_paras(core)

        # НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
        template_clean = core

        # Разрыв страницы рядом с маркером конца цикла (Word хранит его
        # отдельным run в параграфе с <!-- loop_end -->). Если он есть —
        # каждый акт/блок цикла должен начинаться с новой страницы.
        probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
        has_page_break = '<w:br w:type="page"/>' in probe
        # Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его
        # между итерациями, а из хвоста убираем (иначе акты слипаются).
        br_in_suffix = has_page_break and '<w:br w:type="page"/>' in suffix
        if br_in_suffix:
            suffix = re.sub(r'<w:br\b[^>]*?w:type="page"[^>]*?/>', '', suffix)

        # Размножаем шаблон для каждого элемента данных
        expanded_parts = []
        for idx, item in enumerate(items, 1):
            clone = template_clean
            # Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
            # обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
            clone = _expand_nested_loops(clone, item)
            for field, value in item.items():
                if isinstance(value, list):
                    continue  # списки — данные вложенных циклов, не подставляем
                val_str = str(value) if value else ('—' if value is None else '')
                # С префиксом item.
                clone = _subst_var_xml(clone, 'item.%s' % field, val_str)
                # Без префикса (алиасы {{name}}, {{pd_list}}, {{document}} и т.д.)
                clone = _subst_var_xml(clone, '%s' % field, val_str)
                # Альтернативный синтаксис {{item_поле}} (без точки)
                clone = _subst_var_xml(clone, 'item_%s' % field, val_str)
            clone = _subst_var_xml(clone, 'item.number', str(idx))
            clone = _subst_var_xml(clone, 'number', str(idx))
            expanded_parts.append(clone)
            if br_in_suffix:
                expanded_parts.append('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')

        # Собираем новый XML: обрывок параграфа маркера начала (prefix) один раз,
        # затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix)
        new_xml = (doc_xml[:outer.start()] + prefix
                   + ''.join(expanded_parts) + suffix
                   + doc_xml[outer_end.end():])

        # Сохраняем ВСЕ файлы ZIP, обновляя только document.xml
        all_files['word/document.xml'] = new_xml.encode('utf-8')
        with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
            for name, data in all_files.items():
                z.writestr(name, data)
        wrote = True

    if not wrote:
        # Циклов в шаблоне не было — возвращаем исходный файл,
        # пустой временный удаляем (иначе python-docx упадёт с Package not found)
        try:
            os.unlink(tmp.name)
        except Exception:
            pass
        return docx_path

    # Финальная зачистка: пустые параграфы-остатки маркеров (<!-- loop:... -->)
    try:
        with zipfile.ZipFile(tmp.name, 'r') as z:
            all_files = {name: z.read(name) for name in z.namelist()}
        doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
        cleaned = _cleanup_marker_paras(doc_xml)
        if cleaned != doc_xml:
            all_files['word/document.xml'] = cleaned.encode('utf-8')
            with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
                for name, data in all_files.items():
                    z.writestr(name, data)
    except Exception:
        pass

    return tmp.name


# ============================================================
# 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX
# ============================================================

def _apply_highlight_color(run_el, color: str):
    """Добавляет маркер highlight указанного цвета к run."""
    ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
    rpr = run_el.find(f'{{{ns}}}rPr')
    if rpr is None:
        rpr = OxmlElement('w:rPr')
        run_el.insert(0, rpr)
    hl = OxmlElement('w:highlight')
    hl.set(f'{{{ns}}}val', color)
    rpr.append(hl)


def _add_run_with_tabs(para, text, style_source=None):
    """Добавляет run с текстом, сохраняя табуляцию как <w:tab/>.

    python-docx add_run('\t') вставляет символ таба в w:t, который Word
    может «съесть». Поэтому разбиваем текст по '\t' и вставляем
    настоящие элементы табуляции.
    """
    parts = str(text).split('\t')
    for i, part in enumerate(parts):
        if part:
            run = para.add_run(part)
            if style_source is not None:
                _copy_run_style(style_source, run)
        if i < len(parts) - 1:
            run = para.add_run()
            if style_source is not None:
                _copy_run_style(style_source, run)
            run.add_tab()


def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool:
    """Заменяет {{var}} в параграфе. Возвращает True, если были замены.

    Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
    Табуляция в параграфе сохраняется (<w:tab/>).
    """
    full = para.text
    matches = list(VAR_PATTERN.finditer(full))
    if not matches:
        return False

    # Собираем параграф заново: обычный текст + значения переменных
    first = para.runs[0] if para.runs else None
    para.clear()
    pos = 0
    changed = False
    for m in matches:
        var = m.group(0)
        val = replacements.get(var)
        # текст до переменной
        if m.start() > pos:
            _add_run_with_tabs(para, full[pos:m.start()], first)
        if val is None:
            # Переменная не заменена — оставляем как есть,
            # красным её подсветит _highlight_unmatched на шаге 7
            run = para.add_run(var)
            if first is not None:
                _copy_run_style(first, run)
            pos = m.end()
            continue
        # значение переменной
        val_str = str(val)
        run = para.add_run(val_str)
        if first is not None:
            _copy_run_style(first, run)
        if val_str == '—' or val_str == '':
            _apply_highlight_color(run._element, 'yellow')
        changed = True
        pos = m.end()
    # хвост после последней переменной
    if pos < len(full):
        _add_run_with_tabs(para, full[pos:], first)
    return changed


def _copy_run_style(source, target):
    """Копирует форматирование run."""
    try:
        target.bold = source.bold
        target.italic = source.italic
        target.underline = source.underline
        if source.font:
            target.font.size = source.font.size
            target.font.name = source.font.name
    except Exception:
        pass


def _replace_in_paragraphs(paragraphs, replacements):
    for para in paragraphs:
        _replace_text_in_para(para, replacements)


def _replace_in_tables(tables, replacements):
    for table in tables:
        for row in table.rows:
            for cell in row.cells:
                _replace_in_paragraphs(cell.paragraphs, replacements)


def _replace_in_headers_footers(doc, replacements):
    for section in doc.sections:
        for hf in [section.header, section.footer,
                   section.first_page_header, section.first_page_footer]:
            if hf:
                _replace_in_paragraphs(hf.paragraphs, replacements)


def _strip_prooferr(doc):
    """Удаляет proofErr из docx (красные волнистые линии)."""
    ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
    for elem in doc.element.iter():
        for child in list(elem):
            if child.tag == f'{{{ns}}}proofErr':
                elem.remove(child)


def _highlight_unmatched(doc):
    """Выделяет незаменённые переменные красным маркером (только саму переменную)."""
    ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'

    def _apply_highlight(run_el):
        rpr = run_el.find(f'{{{ns}}}rPr')
        if rpr is None:
            rpr = OxmlElement('w:rPr')
            run_el.insert(0, rpr)
        hl = OxmlElement('w:highlight')
        hl.set(f'{{{ns}}}val', 'red')
        rpr.append(hl)

    def _process_para(para):
        full = para.text
        matches = [m for m in UNMATCHED_PATTERN.finditer(full)
                   if m.group(0) not in IGNORED_OLD_VARS]
        if not matches:
            return

        # Пересобираем параграф: обычный текст и переменные отдельными run'ами,
        # переменные — с красным маркером. Сохраняем стиль первого run и табы.
        first = para.runs[0] if para.runs else None
        para.clear()
        pos = 0
        for m in matches:
            if m.start() > pos:
                _add_run_with_tabs(para, full[pos:m.start()], first)
            run = para.add_run(m.group(0))
            if first is not None:
                _copy_run_style(first, run)
            _apply_highlight(run._element)
            pos = m.end()
        if pos < len(full):
            _add_run_with_tabs(para, full[pos:], first)
            run = para.add_run(full[pos:])
            if first is not None:
                _copy_run_style(first, run)

    for para in doc.paragraphs:
        _process_para(para)

    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for para in cell.paragraphs:
                    _process_para(para)

    for section in doc.sections:
        for hf in [section.header, section.footer,
                   section.first_page_header, section.first_page_footer]:
            if hf is not None:
                for para in hf.paragraphs:
                    _process_para(para)


def _log_unmatched(doc, replacements=None, log_func=None):
    """Логирует незаменённые переменные (без клавиш-исключений)."""
    if not log_func:
        return
    unmatched = []
    seen = set()
    for para in doc.paragraphs:
        for m in UNMATCHED_PATTERN.finditer(para.text):
            var = m.group(0)
            if var in IGNORED_OLD_VARS:
                continue
            if var not in seen:
                seen.add(var)
                unmatched.append(var)
    if unmatched:
        log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): "
                 f"{', '.join(unmatched[:20])}"
                 f"{' ...' if len(unmatched) > 20 else ''}")
    else:
        log_func("✅ Все переменные успешно заменены")


# ============================================================
# 4. МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ
# ============================================================

def _migrate_old_vars(doc):
    """Заменяет <OldVar> на {{new_var}}."""
    def _migrate_text(text):
        return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)

    for para in doc.paragraphs:
        new_text = _migrate_text(para.text)
        if new_text != para.text:
            if para.runs:
                first = para.runs[0]
                para.clear()
                run = para.add_run(new_text)
                _copy_run_style(first, run)
            else:
                para.clear()
                para.add_run(new_text)

    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for para in cell.paragraphs:
                    new_text = _migrate_text(para.text)
                    if new_text != para.text:
                        if para.runs:
                            first = para.runs[0]
                            para.clear()
                            run = para.add_run(new_text)
                            _copy_run_style(first, run)
                        else:
                            para.clear()
                            para.add_run(new_text)

    for section in doc.sections:
        for hf in [section.header, section.footer,
                   section.first_page_header, section.first_page_footer]:
            if hf:
                for para in hf.paragraphs:
                    new_text = _migrate_text(para.text)
                    if new_text != para.text:
                        if para.runs:
                            first = para.runs[0]
                            para.clear()
                            run = para.add_run(new_text)
                            _copy_run_style(first, run)
                        else:
                            para.clear()
                            para.add_run(new_text)


# ============================================================
# 5. ОСНОВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ
# ============================================================

def process_template(
    template_path: str,
    replacements: Dict[str, str],
    output_path: Optional[str] = None,
    log_func: Optional[Callable] = None,
) -> str:
    """Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение.
    
    Args:
        template_path: путь к файлу шаблона .docx
        replacements: словарь {переменная: значение}
        output_path: путь для сохранения результата
        log_func: функция логирования (str → None)
    
    Returns:
        путь к сгенерированному файлу
    """
    if not DOCX_AVAILABLE:
        raise ImportError("python-docx не установлен")

    if not os.path.exists(template_path):
        raise FileNotFoundError(f"Шаблон не найден: {template_path}")

    if output_path is None:
        base, ext = os.path.splitext(template_path)
        output_path = f"{base}_result{ext}"

    # ШАГ 1: Обрабатываем циклы на уровне ZIP/XML (до загрузки python-docx)
    loops = replacements.pop('_loops', {})
    working_path = template_path
    if loops:
        working_path = expand_loops_in_zip(template_path, loops)
        replacements['_loops'] = loops  # возвращаем на всякий случай

    # ШАГ 2: Загружаем через python-docx
    doc = Document(working_path)

    # ШАГ 3: Миграция старых переменных <...> → {{...}}
    _migrate_old_vars(doc)

    # ШАГ 4: Убираем proofErr
    _strip_prooferr(doc)

    # ШАГ 5: Замена переменных (3 прохода)
    for _ in range(3):
        _replace_in_paragraphs(doc.paragraphs, replacements)
        _replace_in_tables(doc.tables, replacements)
        _replace_in_headers_footers(doc, replacements)

    # ШАГ 6: Логирование незаменённых
    _log_unmatched(doc, replacements, log_func)

    # ШАГ 7: Подсветка незаполненных
    _highlight_unmatched(doc)

    # ШАГ 8: Сохранение
    doc.save(output_path)

    # ШАГ 9: Если использовали временный файл — удаляем
    if working_path != template_path:
        try:
            os.unlink(working_path)
        except Exception:
            pass

    if log_func:
        log_func(f"✅ {os.path.basename(output_path)}")
    return output_path
