# -*- coding: utf-8 -*-
"""구/신 Document 비교 → ChangeItem 목록. 모든 항목에 근거(old/new/where) 필수."""

from __future__ import annotations

import difflib
import re
from collections import Counter
from dataclasses import dataclass

from watcher.extract.base import Document, find_tags

# TAG 없는 표의 행번호 키 (xlsx.py가 부여; 다중 시트에서는 '시트명!행N')
_POS_KEY = re.compile(r"^(?:[^!]*!)?행\d+")
# 순번 성격의 열: 행이 삽입되면 이후 전부 밀려 '변경'처럼 보이므로 비교에서 제외
_ORDINAL_COL = re.compile(r"^(NO\.?|번호|순번|연번|NR\.?|ITEM ?NO\.?)$", re.IGNORECASE)

# kind 종류:
#   FIELD_CHANGED             표제란 값 변경 (REV, DATE 등)
#   ROW_ADDED/REMOVED/CHANGED 표(BOM 등) 행 변경
#   REVROW_ADDED/REMOVED      리비전 블록 행 변경
#   TEXT_CHANGED/ADDED/REMOVED 본문 줄 변경
CONTENT_KINDS = {"ROW_ADDED", "ROW_REMOVED", "ROW_CHANGED",
                 "TEXT_ADDED", "TEXT_REMOVED", "TEXT_CHANGED",
                 "PAGE_ADDED", "PAGE_REMOVED", "PAGE_VISUAL_CHANGED"}


@dataclass(frozen=True)
class ChangeItem:
    kind: str
    key: str
    old: str | None
    new: str | None
    where: str
    label: str | None = None  # 사람용 식별 — 행 변경이면 품명·규격 (2026-08-25 요청)


# 행의 '무슨 부품/품목인지'를 보여줄 열 (우선순위 순). BOM·발주서·자재표 공통 관례
_LABEL_COLS = ("품명", "부품명", "자재명", "명칭", "품목", "DESCRIPTION", "NAME",
               "PART", "ITEM", "규격", "SPEC", "MODEL", "형식", "TYPE", "MAKER")


def _row_label(values: dict) -> str | None:
    """행에서 사람이 알아볼 식별값(품명+규격 등 최대 2개)을 뽑는다."""
    out: list[str] = []
    for want in _LABEL_COLS:
        for col, val in values.items():
            cu = col.upper().strip()
            if _ORDINAL_COL.match(cu):
                continue  # 'ITEM NO' 같은 순번 열이 ITEM에 걸려 번호가 품명 되는 것 방지
            if (cu == want or want in cu) and val and val not in out:
                out.append(str(val))
                break
        if len(out) >= 2:
            break
    return " · ".join(out) if out else None


def _diff_fields(old: Document, new: Document) -> list[ChangeItem]:
    items = []
    for name in sorted(old.fields.keys() | new.fields.keys()):
        o, n = old.fields.get(name), new.fields.get(name)
        ov = o.value if o else None
        nv = n.value if n else None
        if ov != nv:
            where = (n or o).where
            items.append(ChangeItem("FIELD_CHANGED", name, ov, nv, where))
    return items


def _keyed(rows) -> dict:
    """행을 key로 사전화하되, 중복 key는 #2, #3 순번을 붙여 유실을 막는다.
    (같은 TAG가 여러 행인 BOM에서 마지막 행만 남아 변경이 사라지는 것 방지)"""
    out, counts = {}, Counter()
    for r in rows:
        counts[r.key] += 1
        k = r.key if counts[r.key] == 1 else f"{r.key}#{counts[r.key]}"
        out[k] = r
    return out


def _rows_changed_items(o, n, prefix: str, skip_ordinal: bool = False) -> list[ChangeItem]:
    out = []
    label = _row_label(n.values) or _row_label(o.values)
    for col in sorted(o.values.keys() | n.values.keys()):
        if skip_ordinal and _ORDINAL_COL.match(col):
            continue  # 순번 밀림은 내용 변경이 아니다
        ov, nv = o.values.get(col), n.values.get(col)
        if ov != nv:
            out.append(ChangeItem(f"{prefix}_CHANGED", f"{n.key}.{col}", ov, nv,
                                  n.where, label=label))
    return out


def _row_sig(r) -> tuple:
    """위치 정렬용 행 서명 — 순번 열은 제외 (삽입 시 전체가 밀려 무의미)."""
    return tuple(sorted((k, v) for k, v in r.values.items()
                        if not _ORDINAL_COL.match(k)))


def _diff_rows_positional(old_rows, new_rows, prefix: str) -> list[ChangeItem]:
    """TAG 없는 표(행번호 키): 위치 정렬로 비교 — 행 하나가 삽입돼도
    이후 행 전체가 밀려 오탐 연쇄(수천 건)가 나지 않는다 (2026-08-23 실사용 발견)."""
    ov = [_row_sig(r) for r in old_rows]
    nv = [_row_sig(r) for r in new_rows]
    sm = difflib.SequenceMatcher(a=ov, b=nv, autojunk=False)
    items: list[ChangeItem] = []
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag == "equal":
            continue
        if tag == "insert":
            for j in range(j1, j2):
                r = new_rows[j]
                items.append(ChangeItem(f"{prefix}_ADDED", r.key, None,
                                        str(r.values), r.where,
                                        label=_row_label(r.values)))
        elif tag == "delete":
            for i in range(i1, i2):
                r = old_rows[i]
                items.append(ChangeItem(f"{prefix}_REMOVED", r.key,
                                        str(r.values), None, r.where,
                                        label=_row_label(r.values)))
        else:  # replace: 겹치는 만큼은 값 변경, 남는 쪽은 추가/삭제
            for k in range(max(i2 - i1, j2 - j1)):
                o = old_rows[i1 + k] if i1 + k < i2 else None
                n = new_rows[j1 + k] if j1 + k < j2 else None
                if o is not None and n is not None:
                    items += _rows_changed_items(o, n, prefix, skip_ordinal=True)
                elif n is not None:
                    items.append(ChangeItem(f"{prefix}_ADDED", n.key, None,
                                            str(n.values), n.where,
                                            label=_row_label(n.values)))
                else:
                    items.append(ChangeItem(f"{prefix}_REMOVED", o.key,
                                            str(o.values), None, o.where,
                                            label=_row_label(o.values)))
    return items


def _diff_rows(old_rows, new_rows, kind_prefix: str) -> list[ChangeItem]:
    # 행번호 키가 지배적이면(=TAG 없는 표) 위치 정렬 방식으로
    def positional_ratio(rows):
        return (sum(1 for r in rows if _POS_KEY.match(r.key)) / len(rows)) if rows else 0
    if kind_prefix == "ROW" and old_rows and new_rows \
            and positional_ratio(old_rows) > 0.5 and positional_ratio(new_rows) > 0.5:
        # 다중 시트 파일은 시트별로 따로 위치 정렬 — 시트를 한 줄로 이으면
        # 앞 시트의 행 증감이 뒤 시트 전체를 밀어 오탐이 된다 (2026-08-26)
        def sheet_of(r) -> str:
            return r.where.split("!", 1)[0] if "!" in r.where else ""

        sheets = sorted({sheet_of(r) for r in old_rows}
                        | {sheet_of(r) for r in new_rows})
        if len(sheets) > 1:
            items: list[ChangeItem] = []
            for s in sheets:
                items += _diff_rows_positional(
                    [r for r in old_rows if sheet_of(r) == s],
                    [r for r in new_rows if sheet_of(r) == s], kind_prefix)
            return items
        return _diff_rows_positional(old_rows, new_rows, kind_prefix)

    old_by = _keyed(old_rows)
    new_by = _keyed(new_rows)
    items = []
    for key in sorted(new_by.keys() - old_by.keys()):
        r = new_by[key]
        items.append(ChangeItem(f"{kind_prefix}_ADDED", key, None, str(r.values),
                                r.where, label=_row_label(r.values)))
    for key in sorted(old_by.keys() - new_by.keys()):
        r = old_by[key]
        items.append(ChangeItem(f"{kind_prefix}_REMOVED", key, str(r.values), None,
                                r.where, label=_row_label(r.values)))
    for key in sorted(old_by.keys() & new_by.keys()):
        o, n = old_by[key], new_by[key]
        if o.values != n.values:
            label = _row_label(n.values) or _row_label(o.values)
            for col in sorted(o.values.keys() | n.values.keys()):
                ov, nv = o.values.get(col), n.values.get(col)
                if ov != nv:
                    items.append(ChangeItem(f"{kind_prefix}_CHANGED", f"{key}.{col}",
                                            ov, nv, n.where, label=label))
    return items


def _tag_of(text: str) -> str | None:
    tags = find_tags(text)  # 제외 접두(도면번호 등)를 거친 첫 태그
    return tags[0] if tags else None


def _excess_instances(lines, own_counts: Counter, other_counts: Counter) -> list:
    """상대쪽보다 많은 만큼(N−M개)만 골라낸다 — 전 인스턴스를 지목하면
    아직 존재하는 줄을 삭제됐다고 보고하는 거짓 근거가 된다."""
    taken: Counter = Counter()
    out = []
    for t in lines:
        excess = own_counts[t.text] - other_counts[t.text]
        if taken[t.text] < excess:
            taken[t.text] += 1
            out.append(t)
    return out


# where가 "N페이지 M행" 꼴이면 페이지 정보가 있는 문서 (pdf.py 형식)
_PAGE_WHERE = re.compile(r"^(\d+)페이지")
# 페이지 짝짓기 최소 유사도 — 이보다 다르면 같은 도면의 개정판이 아니라 다른 페이지
_PAGE_SIM_MIN = 0.3
# 페이지 짝짓기 전수 비교 상한 (P×Q). 넘으면 순서대로 짝짓는다 (성능 보호)
_PAGE_PAIR_CAP = 40_000


def _pages(lines) -> dict[int, list]:
    """TextLine들을 페이지 번호로 묶는다. 페이지 정보 없는 줄은 0번 묶음."""
    out: dict[int, list] = {}
    for t in lines:
        m = _PAGE_WHERE.match(t.where)
        out.setdefault(int(m.group(1)) if m else 0, []).append(t)
    return out


def _page_added(pno: int, lines) -> ChangeItem:
    return ChangeItem("PAGE_ADDED", f"{pno}페이지", None,
                      f"페이지 추가 (텍스트 {len(lines)}줄)", lines[0].where)


def _page_removed(pno: int, lines) -> ChangeItem:
    return ChangeItem("PAGE_REMOVED", f"{pno}페이지",
                      f"페이지 삭제 (텍스트 {len(lines)}줄)", None, lines[0].where)


def _pair_changed_pages(old_ids, new_ids, op, np_) -> list[ChangeItem]:
    """내용이 달라진 페이지들을 토큰 겹침 비율로 짝짓는다.
    (재출력 시 페이지 순서가 밀리거나 바뀌어도 같은 도면끼리 비교되도록.
    비율 분모는 작은 쪽 — 한 줄짜리 페이지의 값 변경도 짝지어지게)"""
    items: list[ChangeItem] = []
    if len(old_ids) * len(new_ids) > _PAGE_PAIR_CAP:
        for i, j in zip(old_ids, new_ids):
            items += _diff_text_lines(op[i], np_[j])
        for i in old_ids[len(new_ids):]:
            items.append(_page_removed(i, op[i]))
        for j in new_ids[len(old_ids):]:
            items.append(_page_added(j, np_[j]))
        return items

    otok = {i: {tok for t in op[i] for tok in t.text.split()} for i in old_ids}
    ntok = {j: {tok for t in np_[j] for tok in t.text.split()} for j in new_ids}
    sims = []
    for i in old_ids:
        for j in new_ids:
            inter = len(otok[i] & ntok[j])
            floor = min(len(otok[i]), len(ntok[j])) or 1
            sims.append((inter / floor, i, j))
    used_o, used_n, pairs = set(), set(), []
    for sim, i, j in sorted(sims, key=lambda s: (-s[0], abs(s[1] - s[2]))):
        if sim < _PAGE_SIM_MIN:
            break
        if i in used_o or j in used_n:
            continue
        used_o.add(i)
        used_n.add(j)
        pairs.append((i, j))
    for i, j in sorted(pairs, key=lambda p: p[1]):
        items += _diff_text_lines(op[i], np_[j])
    for i in old_ids:
        if i not in used_o:
            items.append(_page_removed(i, op[i]))
    for j in new_ids:
        if j not in used_n:
            items.append(_page_added(j, np_[j]))
    return items


def _diff_pages(op: dict, np_: dict) -> list[ChangeItem]:
    """페이지 단위 본문 diff. 도면 세트 PDF는 페이지 하나가 별개 도면이므로
    문서 전체를 한 풀로 섞으면 ①페이지 간 이동이 무변경으로 오인되고
    ②문서가 클수록 다른 페이지의 같은 단어가 진짜 변경을 상쇄해 삼킨다
    (2026-08-23 실사용 발견: 131페이지 도면 세트에서 변경 2/3가 유실).

    1단계: 내용이 완전히 같은 페이지끼리 상쇄 (순서 이동은 변경이 아님).
    2단계: 남은 페이지를 유사도로 짝지어 페이지별 줄 diff.
    3단계: 짝 못 찾은 페이지는 페이지 추가/삭제로 보고."""
    osig = {i: tuple(sorted(t.text for t in op[i])) for i in sorted(op)}
    nsig = {j: tuple(sorted(t.text for t in np_[j])) for j in sorted(np_)}
    n_avail = Counter(nsig.values())
    rem_old = []
    for i, s in osig.items():
        if n_avail[s] > 0:
            n_avail[s] -= 1
        else:
            rem_old.append(i)
    o_avail = Counter(osig.values())
    rem_new = []
    for j, s in nsig.items():
        if o_avail[s] > 0:
            o_avail[s] -= 1
        else:
            rem_new.append(j)
    return _pair_changed_pages(rem_old, rem_new, op, np_)


def _diff_texts(old: Document, new: Document) -> list[ChangeItem]:
    """본문 줄 diff. 페이지 정보가 있는 다중 페이지 문서는 페이지끼리 짝지어
    비교하고, 그 외에는 문서 전체를 한 번에 비교한다."""
    op, np_ = _pages(old.raw_texts), _pages(new.raw_texts)
    if len(op) > 1 or len(np_) > 1:
        return _group_repeats(_diff_pages(op, np_), page_count=max(len(op), len(np_)))
    return _group_repeats(_diff_text_lines(old.raw_texts, new.raw_texts))


def _diff_text_lines(old_lines, new_lines) -> list[ChangeItem]:
    """한 비교 단위(문서 전체 또는 페이지 한 쌍)의 줄 diff.
    같은 태그를 가진 삭제+추가 줄은 '변경' 한 건으로 짝짓는다.

    재배치 소음 상쇄: PDF는 같은 내용도 재출력 시 줄 나뉨이 달라져 삭제+추가
    수백 쌍이 생긴다(2026-08-23 실사용 발견). 삭제/추가 줄의 단어 풀을 서로 빼서,
    '실제로 사라진/새로 생긴 단어'를 하나도 안 담은 줄은 줄바꿈·이동으로 보고
    보고하지 않는다. (단어가 우연히 다른 줄에 있는 진짜 삭제는 묻힐 수 있는
    트레이드오프 — 내용 보존이 확인되므로 감수. 페이지 단위로 좁혀 부작용 최소화)"""
    old_counts = Counter(t.text for t in old_lines)
    new_counts = Counter(t.text for t in new_lines)
    removed = _excess_instances(old_lines, old_counts, new_counts)
    added = _excess_instances(new_lines, new_counts, old_counts)

    rm_tokens = Counter(tok for t in removed for tok in t.text.split())
    ad_tokens = Counter(tok for t in added for tok in t.text.split())
    real_rm = rm_tokens - ad_tokens
    real_ad = ad_tokens - rm_tokens
    removed = [t for t in removed if any(tok in real_rm for tok in t.text.split())]
    added = [t for t in added if any(tok in real_ad for tok in t.text.split())]

    items = []
    paired: set = set()
    removed_by_tag = {}
    for t in removed:
        tag = _tag_of(t.text)
        if tag and tag not in removed_by_tag:
            removed_by_tag[tag] = t
    for t in added:
        tag = _tag_of(t.text)
        if tag and tag in removed_by_tag and removed_by_tag[tag] not in paired:
            o = removed_by_tag[tag]
            paired.add(o)
            items.append(ChangeItem("TEXT_CHANGED", tag, o.text, t.text, t.where))
        else:
            items.append(ChangeItem("TEXT_ADDED", tag or t.text[:30], None, t.text, t.where))
    for t in removed:
        if t not in paired:
            key = _tag_of(t.text) or t.text[:30]
            items.append(ChangeItem("TEXT_REMOVED", key, t.text, None, t.where))
    return items


def _group_repeats(items: list[ChangeItem], page_count: int = 0) -> list[ChangeItem]:
    """같은 텍스트의 추가/삭제 반복을 1건으로 묶는다 ('외 N곳' 표기).
    페이지마다 반복되는 표제란 라벨·회사명이 페이지 수 변화로 수십 건씩
    나열되던 소음 제거 (2026-08-23 실사용 발견: 331건 → 고유 121종).

    페이지 절반 이상에서 반복된 변경은 '전 페이지 공통'으로 표시한다 —
    표제란·테두리 양식 변경이 회로 변경과 섞여 보고서를 도배하는 것 구분용."""
    order: list = []          # 첫 등장 순서의 (그룹키 또는 원본 항목)
    first: dict[tuple, ChangeItem] = {}
    counts: Counter = Counter()
    for i in items:
        if i.kind in ("TEXT_ADDED", "TEXT_REMOVED"):
            gkey = (i.kind, i.old, i.new)
            counts[gkey] += 1
            if gkey not in first:
                first[gkey] = i
                order.append(gkey)
        else:
            order.append(i)
    out: list[ChangeItem] = []
    for entry in order:
        if isinstance(entry, ChangeItem):
            out.append(entry)
            continue
        i, n = first[entry], counts[entry]
        where = i.where if n == 1 else f"{i.where} 외 {n - 1}곳"
        if page_count >= 4 and n >= (page_count + 1) // 2:
            where += " — 전 페이지 공통(표제란·양식 변경 가능성)"
        out.append(ChangeItem(i.kind, i.key, i.old, i.new, where))
    return out


def diff_documents(old: Document, new: Document) -> list[ChangeItem]:
    items = _diff_fields(old, new)
    items += _diff_rows(old.rows, new.rows, "ROW")
    items += _diff_rows(old.revision_history, new.revision_history, "REVROW")
    items += _diff_texts(old, new)
    return items


def content_items(items: list[ChangeItem]) -> list[ChangeItem]:
    """'내용' 변경만 (표제란·리비전 블록 제외). Rev 일관성 판단의 기준."""
    return [i for i in items if i.kind in CONTENT_KINDS]
