# -*- coding: utf-8 -*-
"""변경 위치 캡처 + 페이지 시각 변경 감지.

형상의 '의미 해석'은 하지 않는다(PRINCIPLE 7, 2026-08-24 개정) — 어느 페이지가
시각적으로 달라졌다는 사실과 전/후 이미지까지만 제시하고, 무엇이 어떻게
바뀌었는지의 해석은 사람 몫이다.
읽기 전용: 원본 파일에는 절대 쓰지 않으며, 빨간 박스는 렌더된 이미지 위에만 그린다.
캡처는 최선 노력(best-effort)이다 — 실패해도 감시·브리핑은 계속된다.
엔진: pypdfium2 + Pillow (허용적 라이선스 — 2026-08-26 AGPL 교체 결정).
"""

from __future__ import annotations

import base64
import io
import math
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

# 페이지 시각 비교 파라미터
_VIS_DPI = 100          # 감지용 렌더 해상도 (가는 배선도 픽셀로 남는 수준)
_VIS_PIXEL_MIN = 40     # 이보다 큰 명도 차이만 '다른 픽셀' (안티앨리어싱 소음 제거)
_VIS_BLOCK = 16         # 변경 픽셀 뭉침 판정 블록(px)
_VIS_BLOCK_MIN_PX = 8   # 블록 안 변경 픽셀이 이 이상이면 '변경 블록'
_VIS_CLUSTER_MIN = 2    # 인접 변경 블록이 이 이상 뭉치면 '변경 위치' (점 소음 제거)
_VIS_REGIONS_PER_PAGE = 3   # 페이지당 보고할 변경 위치 수 상한
_VIS_CROP_PAD = 60      # 변경 위치 주변 여백(pt)
_VIS_JPG_QUALITY = 70


@dataclass(frozen=True)
class _Region:
    """페이지 위 사각 영역 — 좌상단 원점(pt). 렌더 이미지 좌표와 방향이 같다."""
    x0: float
    y0: float
    x1: float
    y1: float


def _jpeg_b64(pil_img, quality: int = _VIS_JPG_QUALITY) -> str:
    buf = io.BytesIO()
    pil_img.convert("RGB").save(buf, format="JPEG", quality=quality)
    return base64.b64encode(buf.getvalue()).decode("ascii")


def _png_b64(pil_img) -> str:
    buf = io.BytesIO()
    pil_img.save(buf, format="PNG")
    return base64.b64encode(buf.getvalue()).decode("ascii")


def _dxf_svg(path: Path, target_text: str, pad: float = 55.0) -> dict | None:
    """DXF에서 target 텍스트(줄의 첫 토큰) 주변을 SVG로 렌더링 + 빨간 박스."""
    import ezdxf

    doc = ezdxf.readfile(path)
    msp = doc.modelspace()
    token = target_text.split()[0] if target_text.split() else target_text

    target = None
    for e in msp.query("TEXT"):
        if e.dxf.text.strip() == token:
            target = e
            break
    if target is None:
        return None
    tx, ty = target.dxf.insert.x, target.dxf.insert.y
    th = float(target.dxf.height or 3.0)

    x0, y0, x1, y1 = tx - pad, ty - pad * 0.55, tx + pad * 2.2, ty + pad * 0.55

    def inside(x, y):
        return x0 - 5 <= x <= x1 + 5 and y0 - 5 <= y <= y1 + 5

    def sx(x):
        return round(x - x0, 1)

    def sy(y):
        return round(y1 - y, 1)  # SVG는 y가 아래로 증가 → 뒤집기

    parts = []
    for e in msp:
        t = e.dxftype()
        if t == "LINE":
            a, b = e.dxf.start, e.dxf.end
            if inside(a.x, a.y) or inside(b.x, b.y):
                parts.append(f'<line x1="{sx(a.x)}" y1="{sy(a.y)}" x2="{sx(b.x)}" '
                             f'y2="{sy(b.y)}" stroke="#334155" stroke-width="0.7"/>')
        elif t == "LWPOLYLINE":
            pts = [(p[0], p[1]) for p in e.get_points()]
            if any(inside(x, y) for x, y in pts):
                d = " ".join(f"{sx(x)},{sy(y)}" for x, y in pts)
                closed = ' points="' + d + '"'
                tag = "polygon" if e.closed else "polyline"
                parts.append(f'<{tag}{closed} fill="none" stroke="#334155" '
                             f'stroke-width="0.7"/>')
        elif t == "CIRCLE":
            c = e.dxf.center
            if inside(c.x, c.y):
                parts.append(f'<circle cx="{sx(c.x)}" cy="{sy(c.y)}" r="{e.dxf.radius}" '
                             f'fill="none" stroke="#334155" stroke-width="0.7"/>')
        elif t == "ARC":
            c = e.dxf.center
            if inside(c.x, c.y):
                r = e.dxf.radius
                a0, a1 = math.radians(e.dxf.start_angle), math.radians(e.dxf.end_angle)
                if a1 < a0:
                    a1 += math.tau
                pts = [(c.x + r * math.cos(a0 + (a1 - a0) * i / 16),
                        c.y + r * math.sin(a0 + (a1 - a0) * i / 16)) for i in range(17)]
                d = " ".join(f"{sx(x)},{sy(y)}" for x, y in pts)
                parts.append(f'<polyline points="{d}" fill="none" stroke="#334155" '
                             f'stroke-width="0.7"/>')
        elif t == "TEXT":
            p = e.dxf.insert
            if inside(p.x, p.y):
                h = float(e.dxf.height or 3.0)
                import html as _html
                parts.append(f'<text x="{sx(p.x)}" y="{sy(p.y)}" font-size="{h * 1.15}" '
                             f'font-family="Consolas,monospace" fill="#0f172a">'
                             f"{_html.escape(e.dxf.text)}</text>")

    # 변경 위치 하이라이트 (빨간 박스)
    bw = max(len(target_text) * th * 0.75, th * 6)
    parts.append(f'<rect x="{sx(tx) - 2}" y="{sy(ty) - th * 1.5}" width="{bw:.1f}" '
                 f'height="{th * 2.4:.1f}" fill="none" stroke="#dc2626" '
                 f'stroke-width="1.1" rx="2"/>')

    w, h = x1 - x0, y1 - y0
    svg = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w:.0f} {h:.0f}" '
           f'width="540" style="background:#fff">' + "".join(parts) + "</svg>")
    return {
        "mime": "image/svg+xml",
        "b64": base64.b64encode(svg.encode("utf-8")).decode("ascii"),
        "caption": f"변경 위치: 레이어 {target.dxf.layer}, 좌표 ({tx:.0f}, {ty:.0f})",
    }


def _render_region(page, region: _Region, scale: float = 2.0,
                   box: _Region | None = None):
    """페이지의 region(좌상단 원점 pt)만 렌더한 PIL 이미지. box가 있으면 빨간 테두리.
    원본 PDF는 읽기만 하고, 표시는 렌더된 픽셀 위에만 한다."""
    from PIL import ImageDraw

    w, h = page.get_size()
    x0 = max(0.0, region.x0)
    y0 = max(0.0, region.y0)
    x1 = min(float(w), region.x1)
    y1 = min(float(h), region.y1)
    # pdfium crop = (왼쪽, 아래, 오른쪽, 위)에서 잘라낼 양 — 아래 원점 좌표계
    crop = (x0, h - y1, w - x1, y0)
    img = page.render(scale=scale, crop=crop).to_pil()
    if box is not None:
        draw = ImageDraw.Draw(img)
        draw.rectangle(
            [(box.x0 - x0 - 4) * scale, (box.y0 - y0 - 4) * scale,
             (box.x1 - x0 + 4) * scale, (box.y1 - y0 + 4) * scale],
            outline=(220, 33, 33), width=2)
    return img


def _pdf_png(path: Path, target_text: str) -> dict | None:
    """PDF에서 target 줄을 찾아 주변을 잘라 PNG + 빨간 박스 (렌더 이미지 위에만 표시)."""
    import pypdfium2 as pdfium

    pdf = pdfium.PdfDocument(Path(path).read_bytes())  # 핸들 즉시 반납
    try:
        for pno in range(1, len(pdf) + 1):
            page = pdf[pno - 1]
            textpage = page.get_textpage()
            found = textpage.search(target_text, match_case=True).get_next()
            if found is None:
                textpage.close()
                page.close()
                continue
            idx, cnt = found
            n = textpage.count_rects(idx, cnt)
            if not n:
                textpage.close()
                page.close()
                continue
            left, bottom, right, top = textpage.get_rect(0)
            _, h = page.get_size()
            box = _Region(left, h - top, right, h - bottom)  # 좌상단 원점으로 변환
            area = _Region(box.x0 - 170, box.y0 - 70, box.x1 + 170, box.y1 + 70)
            img = _render_region(page, area, scale=2.0, box=box)
            out = {
                "mime": "image/png",
                "b64": _png_b64(img),
                "caption": f"변경 위치: {pno}페이지, 좌표 ({box.x0:.0f}, {box.y0:.0f})",
            }
            textpage.close()
            page.close()
            return out
    finally:
        pdf.close()
    return None


def _one(abs_path: Path, text: str) -> dict | None:
    try:
        ext = Path(abs_path).suffix.lower()
        if ext == ".dxf":
            return _dxf_svg(Path(abs_path), text)
        if ext == ".pdf":
            return _pdf_png(Path(abs_path), text)
    except Exception:
        return None  # 캡처 실패는 감시를 막지 않는다
    return None


def capture_for(abs_path: Path, new_text: str) -> dict | None:
    """변경된 줄(new_text)의 위치 캡처(신버전). 지원 유형이 아니거나 실패하면 None."""
    return _one(abs_path, new_text) if new_text else None


def capture_pair(new_path: Path, new_text: str,
                 old_path: Path | None = None,
                 old_text: str | None = None) -> dict | None:
    """전/후 캡처 쌍. 결과 dict:
      {'after': {...}, 'before': {...}|None}
    old_path가 있으면(판 짝짓기 = 이전 파일이 남아 있는 경우) 전 사진도 만든다.
    덮어쓰기라 구버전 파일이 없으면 before=None (후 사진만)."""
    after = _one(new_path, new_text) if new_text else None
    if after is None:
        return None
    before = None
    if old_path is not None and Path(old_path).is_file():
        before = _one(Path(old_path), old_text or new_text)
    return {"after": after, "before": before}


def _gray_array(page, dpi: int = _VIS_DPI):
    """페이지 전체를 그레이스케일 numpy 배열로."""
    return page.render(scale=dpi / 72.0, grayscale=True).to_numpy()


def _changed_blocks(a, b, pixel_min: int = _VIS_PIXEL_MIN,
                    block_min: int = _VIS_BLOCK_MIN_PX):
    """두 그레이 렌더(numpy 2D)의 '변경 블록' 좌표 집합 {(bx, by)}."""
    import numpy as np
    h, w = a.shape
    mask = (np.abs(a.astype(np.int16) - b.astype(np.int16)) > pixel_min)
    gh, gw = h // _VIS_BLOCK + 1, w // _VIS_BLOCK + 1
    padded = np.zeros((gh * _VIS_BLOCK, gw * _VIS_BLOCK), dtype=bool)
    padded[:h, :w] = mask
    sums = padded.reshape(gh, _VIS_BLOCK, gw, _VIS_BLOCK).sum(axis=(1, 3))
    ys, xs = np.nonzero(sums >= block_min)
    return {(int(x), int(y)) for x, y in zip(xs, ys)}


def _clusters(blocks: set, cluster_min: int = _VIS_CLUSTER_MIN) -> list:
    """인접(8방향) 변경 블록을 뭉쳐 위치별 블록 묶음 목록으로. 큰 묶음 순."""
    seen: set = set()
    out = []
    for start in blocks:
        if start in seen:
            continue
        group, todo = [], [start]
        seen.add(start)
        while todo:
            bx, by = todo.pop()
            group.append((bx, by))
            for dx in (-1, 0, 1):
                for dy in (-1, 0, 1):
                    nb = (bx + dx, by + dy)
                    if nb in blocks and nb not in seen:
                        seen.add(nb)
                        todo.append(nb)
        if len(group) >= cluster_min:
            out.append(group)
    out.sort(key=len, reverse=True)
    return out


def _region_crop(pdf, pno: int, rect: _Region, label: str) -> dict:
    """변경 위치 주변을 확대해 잘라낸 이미지 + 빨간 박스 (렌더 이미지 위에만 표시)."""
    page = pdf[pno - 1]
    try:
        area = _Region(rect.x0 - _VIS_CROP_PAD, rect.y0 - _VIS_CROP_PAD,
                       rect.x1 + _VIS_CROP_PAD, rect.y1 + _VIS_CROP_PAD)
        img = _render_region(page, area, scale=2.0, box=rect)
        return {
            "mime": "image/jpeg",
            "b64": _jpeg_b64(img),
            "caption": f"{pno}페이지 변경 위치 확대 ({label})",
        }
    finally:
        page.close()


def visual_page_diff(old_path: Path, new_path: Path,
                     capture_top: int | None = None,
                     sensitive: bool = False) -> tuple[list, dict]:
    """PDF 두 판의 페이지별 픽셀 비교 → 변경 '위치' 목록.

    전체 면적 비율이 아니라 변경 픽셀의 뭉침(블록 클러스터)으로 판정한다 —
    가는 배선 한 가닥의 추가/삭제도 위치로 잡히고, 점 소음은 걸러진다
    (2026-08-24 실사용 피드백: 면적 기준은 글자만 잡고 배선 변경을 놓침).

    돌려주는 값: ([(key, where)], {key: {'before':…, 'after':…}})
      key   = "17페이지 위치1" (ChangeItem.key와 캡처 키로 공용)
      where = "17페이지 좌표 (x, y) 부근"
    모든 위치에 전/후 확대 사진 첨부 (기본). capture_top을 주면 변경이 큰
    순으로 그 수만 첨부.
    실패하면 None — '시각 변경 없음'([], {})과 구분해 호출부가 '비교 못 함'을
    고지할 수 있게 (2026-08-26 리뷰: 침묵 실패는 원칙 4 위반)."""
    import pypdfium2 as pdfium

    od = nd = None
    try:
        # 메모리로 읽고 파일은 즉시 놓아준다 — 긴 렌더 동안 파일을 잡고 있으면
        # 탐색기에서 이동·이름변경이 거부돼 "폴더 먹통"으로 체감된다 (2026-08-27)
        od = pdfium.PdfDocument(Path(old_path).read_bytes())
        nd = pdfium.PdfDocument(Path(new_path).read_bytes())
    except Exception:
        if od is not None:
            od.close()
        return None
    try:
        scale = 72.0 / _VIS_DPI  # 렌더 px → pt
        page_total = min(len(od), len(nd))
        found = []      # (pno, _Region(pt, 좌상단 원점), 블록수)
        size_diff = []  # 페이지 크기 자체가 바뀐 페이지
        for pno in range(1, page_total + 1):
            po, pn = od[pno - 1], nd[pno - 1]
            try:
                if po.get_size() != pn.get_size():
                    size_diff.append(pno)
                    continue
                a, b = _gray_array(po), _gray_array(pn)
                if a.shape != b.shape:
                    size_diff.append(pno)
                    continue
                if (a == b).all():
                    continue
                # 민감 모드: 임계값 절반 — 놓친 미세 변경 재탐색용 (오탐은
                # 사람이 [오탐 삭제]로 걷어낸다, 2026-08-27 미탐 대응)
                if sensitive:
                    blocks = _changed_blocks(a, b, pixel_min=20, block_min=4)
                else:
                    blocks = _changed_blocks(a, b)
            finally:
                po.close()
                pn.close()
            cmin = 1 if sensitive else _VIS_CLUSTER_MIN
            for group in _clusters(blocks, cluster_min=cmin)[:_VIS_REGIONS_PER_PAGE]:
                xs = [g[0] for g in group]
                ys = [g[1] for g in group]
                rect = _Region(min(xs) * _VIS_BLOCK * scale,
                               min(ys) * _VIS_BLOCK * scale,
                               (max(xs) + 1) * _VIS_BLOCK * scale,
                               (max(ys) + 1) * _VIS_BLOCK * scale)
                found.append((pno, rect, len(group)))

        # 같은 자리(중심 좌표 32pt 격자)가 여러 페이지에서 반복 = 표제란·양식 변경.
        # 페이지마다 나열하면 진짜 개별 변경이 묻힌다 (2026-08-24 실사용: 131페이지
        # 전부에 날짜 갱신 위치가 찍혀 182곳 나열) → 공통 1건으로 묶는다
        loc_groups: dict = {}
        for pno, rect, size in found:
            gkey = (int((rect.x0 + rect.x1) / 2 // 32),
                    int((rect.y0 + rect.y1) / 2 // 32))
            loc_groups.setdefault(gkey, []).append((pno, rect, size))
        common_min = max(4, (page_total + 1) // 2)
        indiv, commons = [], []
        for lst in loc_groups.values():
            pnos = sorted({p for p, _, _ in lst})
            if len(pnos) >= common_min:
                commons.append((pnos, lst[0][1], max(s for _, _, s in lst)))
            else:
                indiv.extend(lst)

        regions, cap_order = [], []  # cap_order: (우선순위, 블록수, key, pno, rect)
        per_page_no: Counter = Counter()
        for pno, rect, size in sorted(indiv, key=lambda f: (f[0], -f[2])):
            per_page_no[pno] += 1
            key = f"{pno}페이지 위치{per_page_no[pno]}"
            regions.append((key, f"{pno}페이지 좌표 ({rect.x0:.0f}, {rect.y0:.0f}) 부근"))
            cap_order.append((0, size, key, pno, rect))
        for pno in size_diff:
            regions.append((f"{pno}페이지", f"{pno}페이지 (페이지 크기 변경)"))
        for ci, (pnos, rect, size) in enumerate(
                sorted(commons, key=lambda c: -len(c[0])), start=1):
            key = f"공통위치{ci}"
            regions.append((key,
                            f"좌표 ({rect.x0:.0f}, {rect.y0:.0f}) 부근 — "
                            f"{len(pnos)}개 페이지 반복 (표제란·양식 변경 가능성)"))
            cap_order.append((1, size, key, pnos[0], rect))

        caps: dict = {}
        for _pri, _size, key, pno, rect in sorted(
                cap_order, key=lambda c: (c[0], -c[1]))[:capture_top]:
            try:
                caps[key] = {
                    "before": _region_crop(od, pno, rect, "이전 판"),
                    "after": _region_crop(nd, pno, rect, "새 판"),
                }
            except Exception:
                continue
        return regions, caps
    except Exception:
        return None
    finally:
        od.close()
        nd.close()


def page_pair_images(old_path: Path, new_path: Path,
                     pno: int) -> tuple[str, str, int] | None:
    """두 판의 같은 페이지 전체를 JPEG(base64)로 — 전/후 나란히 육안 비교용.
    감지가 놓친 변경도 사람이 직접 훑을 수 있는 안전망 (2026-08-27 미탐 대응).
    돌려주는 값: (구판 b64, 신판 b64, 공통 페이지 수). 실패/범위 밖이면 None."""
    import pypdfium2 as pdfium
    try:
        od = pdfium.PdfDocument(Path(old_path).read_bytes())
        nd = pdfium.PdfDocument(Path(new_path).read_bytes())
    except Exception:
        return None
    try:
        total = min(len(od), len(nd))
        if not (1 <= pno <= total):
            return None
        out = []
        for doc in (od, nd):
            page = doc[pno - 1]
            try:
                out.append(_jpeg_b64(page.render(scale=110 / 72.0).to_pil(),
                                     quality=72))
            finally:
                page.close()
        return out[0], out[1], total
    except Exception:
        return None
    finally:
        od.close()
        nd.close()
