# -*- coding: utf-8 -*-
"""XLSX 추출기: BOM·발주서·자재표 행 → Row 목록.

전 시트를 순회한다 — wb.active(저장 당시 열려 있던 시트) 하나만 읽으면 담당자가
어느 시트를 보다 저장했는지에 따라 감시 대상이 조용히 바뀐다 (2026-08-26 설계
엔지니어 검토, PRINCIPLE 5 위반이라 최우선 수정). 실무 BOM은 표지/전장품/케이블/
예비품/개정이력이 한 파일에 시트로 나뉜다.

행 key는 TAG 열 우선, 없으면 '시트명!행N'. 병합 셀은 앵커 값을 전파하고,
소계/합계 행은 비교에서 제외하며, 개정이력 시트는 revision_history로 분류한다.
"""

from __future__ import annotations

import re
from collections import Counter
from pathlib import Path

import openpyxl

from watcher.extract.base import Document, Field, Row

# 표 머리글에 흔한 열 이름 — 이 중 하나라도 있으면 열 2개짜리 표도 머리글로 인정
_KNOWN_HEADER = {"TAG", "SPEC", "품명", "부품명", "자재명", "명칭", "품번", "규격",
                 "수량", "QTY", "DESCRIPTION", "MODEL", "형식", "단위", "UNIT"}

# 개정이력 시트로 취급할 시트명 (revision_history로 분류 — Rev 검사 근거로 쓰임)
_REV_SHEET = re.compile(r"개정|이력|리비전|REV|HISTORY", re.IGNORECASE)

# 소계/합계 행 — 위 행이 하나 늘면 같이 밀려 '변경'으로 잡히는 소음원이라 제외
_TOTAL_ROW = re.compile(r"^(소\s*계|합\s*계|총\s*계|계|SUB\s*TOTAL|TOTAL|G\.?\s*TOTAL)$",
                        re.IGNORECASE)

# 순번 성격 열 — 행 식별자로 승격하면 안 됨 (삽입 시 전부 밀림)
_ORDINAL = re.compile(r"^(NO\.?|번호|순번|연번|NR\.?|ITEM ?NO\.?)$", re.IGNORECASE)

# 식별자 성격의 열 이름 — 점수 가산으로 두 판이 같은 키 열을 고르게 고정
_IDENTITY_HINT = ("품번", "자재번호", "부품번호", "코드", "설명", "내용", "조건",
                  "명칭", "품명", "DESCRIPTION", "ITEM", "CODE", "ID")


def _pick_key_col(row_values: list[dict]) -> str | None:
    """TAG 열이 없을 때 행 식별자로 쓸 열 자동 선택 — 채움률·고유율이 높은
    텍스트 열(설명·품번류). 인터락시트처럼 행 삭제가 잦은 표를 행번호(위치)로
    짝지으면 삭제 이후 전부 어긋나 셀 변경 수천 건 오탐이 된다 (2026-08-27 실사용).
    적합한 열이 없으면 None → 기존 행번호 방식."""
    if not row_values:
        return None
    n = len(row_values)
    best, best_score = None, 0.0
    # sorted: set 순회는 프로세스마다 순서가 달라 동점일 때 다른 열이 뽑힐 수
    # 있다 — 두 판이 다른 키 열을 쓰면 전 행이 어긋난다 (결정성 보장, 2026-08-27)
    cols = sorted(set().union(*[set(v) for v in row_values]))
    for c in cols:
        if _ORDINAL.match(c.strip()):
            continue
        vals = [v[c] for v in row_values if v.get(c)]
        if len(vals) < n * 0.9:          # 거의 모든 행에 있어야
            continue
        uniq = len(set(vals)) / len(vals)
        if uniq < 0.9:                    # 거의 고유해야 (구역명 반복 열 배제)
            continue
        avg = sum(len(v) for v in vals) / len(vals)
        if avg < 4:                       # X/O 마크 열 배제
            continue
        score = uniq * min(avg, 40)       # 고유하고 서술적인 열 우대
        if any(h in c.upper() for h in _IDENTITY_HINT):
            score += 100  # 알려진 식별 열 이름 우선 — 두 판의 키 열 선택을 고정
        if score > best_score:
            best, best_score = c, score
    return best


def _looks_like_header(cells: list[str]) -> bool:
    """머리글 후보: 문자 셀이 3개 이상이고 숫자 위주가 아님.
    실무 BOM은 위에 빈 행·제목 행이 있는 경우가 많다 (2026-08-23 실사용 발견).
    열이 2개뿐인 표는 알려진 열 이름(TAG·품명 등)이 있을 때만 인정
    (2026-08-26 — 제목 행 오인 방지와 소형 표 지원의 절충)."""
    texts = [c for c in cells if c]
    if len(texts) < 2:
        return False
    if len(texts) < 3 and not any(t.upper().strip() in _KNOWN_HEADER for t in texts):
        return False

    def is_num(s: str) -> bool:
        try:
            float(s)
            return True
        except ValueError:
            return False

    numeric = sum(1 for c in texts if is_num(c))
    return numeric <= len(texts) // 2


def _merged_anchor_map(ws) -> dict:
    """병합 범위의 모든 좌표 → 앵커(좌상단) 좌표. 병합 셀 값 전파용 —
    품명이 3~4행 병합된 실무 BOM에서 품명 없는 유령 행이 생기는 것 방지."""
    out: dict[tuple[int, int], tuple[int, int]] = {}
    try:
        for rng in ws.merged_cells.ranges:
            anchor = (rng.min_row, rng.min_col)
            for r in range(rng.min_row, rng.max_row + 1):
                for c in range(rng.min_col, rng.max_col + 1):
                    if (r, c) != anchor:
                        out[(r, c)] = anchor
    except Exception:
        pass
    return out


def _sheet_rows(ws, merged: dict) -> tuple[list[Row], list[str]]:
    """한 시트의 데이터 행. 돌려주는 값: (rows, 머리글).

    머리글은 '첫 그럴듯한 행'이 아니라 **상위 10행 중 열 이름이 가장 많은 행**을
    고른다 — 인터락시트처럼 제목 행(3~4칸)이 위에 있고 진짜 머리글(장비별 열
    수십 개)이 아래 있는 양식에서, 제목을 머리글로 오인해 열이 1개로 붕괴되고
    매트릭스 전체가 버려지던 문제 (2026-08-27 실사용 발견)."""
    rows: list[Row] = []
    grid: dict[tuple[int, int], str] = {}

    def cell_text(r: int, c: int, raw) -> str:
        v = "" if raw is None else str(raw).strip()
        if not v and (r, c) in merged:
            v = grid.get(merged[(r, c)], "")
        grid[(r, c)] = v
        return v

    all_cells: list[tuple[int, list[str]]] = []
    for i, raw in enumerate(ws.iter_rows(values_only=True), start=1):
        all_cells.append((i, [cell_text(i, j, v) for j, v in enumerate(raw, start=1)]))

    header_idx = None
    best = -1
    for i, cells in all_cells[:10]:
        if not _looks_like_header(cells):
            continue
        score = len({c for c in cells if c})  # 서로 다른 열 이름 수
        # 이미 후보가 있으면 확실히 넓을 때(+3)만 교체 — 머리글 아래의 텍스트가
        # 풍부한 데이터 행이 열 한두 개 차이로 머리글을 빼앗지 않게 (2026-08-27)
        need = best + 3 if header_idx is not None else best + 1
        if score >= need:
            best, header_idx = score, i
    if header_idx is None:
        return [], []
    header = [c.upper() for c in dict(all_cells)[header_idx]]

    collected: list[tuple[int, dict]] = []
    for i, cells in all_cells:
        if i <= header_idx:
            continue  # 머리글과 그 위의 빈 행·제목 행은 건너뜀
        if not any(cells):
            continue
        if any(_TOTAL_ROW.match(c) for c in cells if c):
            continue  # 소계/합계 행은 내용이 아니라 집계 — 밀림 소음 방지
        # 머리글이 빈 열은 데이터로 취급하지 않는다 (수백 열짜리 양식 대응)
        values = {h: v for h, v in zip(header, cells) if h and v}
        if values:
            collected.append((i, values))

    # 행 식별자: TAG 열 > 자동 선택된 식별 열(설명·품번류) > 행번호
    key_col = None
    if not any(v.get("TAG") for _, v in collected):
        key_col = _pick_key_col([v for _, v in collected])
    for i, values in collected:
        key = values.get("TAG") \
            or (values.get(key_col) if key_col else None) \
            or f"{ws.title}!행{i}"
        rows.append(Row(key=key, values=values, where=f"{ws.title}!행{i}"))
    return rows, header


def extract(path: Path) -> Document:
    # read_only가 아니어야 병합 범위를 알 수 있다. BOM류는 수 MB라 감당 가능
    wb = openpyxl.load_workbook(path, read_only=False, data_only=True)
    rows: list[Row] = []
    revision_history: list[Row] = []
    for ws in wb.worksheets:
        if ws.sheet_state != "visible":
            continue  # 숨김 시트는 산출물이 아니다
        merged = _merged_anchor_map(ws)
        sheet_rows, _ = _sheet_rows(ws, merged)
        if _REV_SHEET.search(ws.title):
            revision_history.extend(sheet_rows)
        else:
            rows.extend(sheet_rows)
    wb.close()

    fields: dict[str, Field] = {}
    projects = Counter(r.values["PROJECT"] for r in rows if r.values.get("PROJECT"))
    if projects:
        top = projects.most_common(1)[0][0]
        fields["PROJECT"] = Field("PROJECT", top, "PROJECT 열 최빈값")
    return Document(doc_type="xlsx", path=str(path), fields=fields, rows=rows,
                    revision_history=revision_history)
