# -*- coding: utf-8 -*-
"""문서 유형 공통 모델. 모든 추출기는 이 모델로 수렴한다 (문서 유형 독립 원칙).

where 필드는 모든 값의 '근거 위치'다 — 근거 없는 값은 모델에 못 들어온다 (PRINCIPLE 4).
"""

from __future__ import annotations

import re
from dataclasses import asdict, dataclass, field

# 기기 태그 패턴: M-101, X-12, MCCB-01, 1M-1 ... (전장/P&ID 공통 관례).
# 앞에 영숫자·대시가 붙은 것(DWG-E-001의 E-001, 전화번호 등)은 태그가 아니다 —
# 부정 후방탐색으로 차단 (2026-08-26 설계 엔지니어 검토: 오탐이 태그 사전을 오염)
TAG_RE = re.compile(r"(?<![A-Z0-9-])\d?[A-Z]{1,6}-\d+[A-Z0-9]*\b")

# 태그로 오인하기 쉬운 접두 — 도면번호·시트 참조·문서 표기류 (전부 대문자)
TAG_EXCLUDE_PREFIX = {"DWG", "TEL", "FAX", "ISO", "NOTE", "SH", "SHT", "FIG",
                      "DOC", "PAGE", "REV", "NO", "P/N", "KS", "IEC", "JIS"}

_TAG_PREFIX_RE = re.compile(r"^\d?([A-Z]+)")


def set_tag_pattern(pattern: str) -> None:
    """회사별 태그 관례 재정의 (config tag_pattern) — 태그 표기는 회사·고객마다
    제각각이라 정규식 한 줄 커스터마이즈가 현실적이다 (2026-08-26)."""
    global TAG_RE
    TAG_RE = re.compile(pattern)


def is_tag(text: str) -> bool:
    """제외 접두(도면번호·전화 등)를 거른 최종 태그 판정."""
    m = _TAG_PREFIX_RE.match(text)
    return not (m and m.group(1) in TAG_EXCLUDE_PREFIX)


def find_tags(text: str) -> list[str]:
    """한 줄의 모든 태그 — 'M-101 5.5kW M-102 7.5kW'처럼 한 줄 두 기기 대응."""
    return [m.group() for m in TAG_RE.finditer(text) if is_tag(m.group())]


@dataclass(frozen=True)
class Field:
    """표제란(또는 머리말)의 이름-값 한 칸."""
    name: str
    value: str
    where: str


@dataclass(frozen=True)
class Row:
    """표의 한 행 (BOM 행, 리비전 블록 행). key는 행 식별자 (TAG, Rev 등)."""
    key: str
    values: dict
    where: str


@dataclass(frozen=True)
class TextLine:
    """본문 텍스트 한 줄 (같은 줄에 있던 조각들은 추출기가 병합)."""
    text: str
    where: str


@dataclass
class Document:
    doc_type: str                                   # "dxf" | "pdf" | "xlsx" | ...
    path: str
    fields: dict = field(default_factory=dict)      # {name: Field} 표제란
    rows: list = field(default_factory=list)        # [Row] 표 데이터 (BOM 등)
    revision_history: list = field(default_factory=list)  # [Row] 리비전 블록
    raw_texts: list = field(default_factory=list)   # [TextLine] 본문
    notes: list = field(default_factory=list)       # [str] 추출 한계 고지 (예: 텍스트 층 없음)

    @property
    def revision(self) -> str | None:
        f = self.fields.get("REV")
        return f.value if f else None

    @property
    def project(self) -> str | None:
        f = self.fields.get("PROJECT")
        return f.value if f else None

    def tag_lines(self) -> dict:
        """본문에서 기기 태그가 있는 줄: {tag: TextLine} (첫 출현). 일관성 검사용."""
        out: dict[str, TextLine] = {}
        for line in self.raw_texts:
            for tag in find_tags(line.text):
                if tag not in out:
                    out[tag] = line
        return out

    def tag_occurrences(self) -> dict:
        """태그의 전 출현 줄: {tag: [TextLine, ...]}. 같은 태그가 심볼 옆과
        결선표에 두 번 나오는 도면에서 첫 줄만 보면 오탐이 난다 (2026-08-26)."""
        out: dict[str, list[TextLine]] = {}
        for line in self.raw_texts:
            for tag in find_tags(line.text):
                out.setdefault(tag, []).append(line)
        return out

    def to_dict(self) -> dict:
        return asdict(self)

    def sanitized(self) -> "Document":
        """추출 텍스트의 깨진 인코딩 정화본. 원본에 문제가 없으면 자기 자신."""
        d = self.to_dict()
        if not _has_surrogate(d):
            return self
        return Document.from_dict(clean_obj(d))

    @classmethod
    def from_dict(cls, d: dict) -> "Document":
        return cls(
            doc_type=d["doc_type"],
            path=d["path"],
            fields={k: Field(**v) for k, v in d["fields"].items()},
            rows=[Row(**r) for r in d["rows"]],
            revision_history=[Row(**r) for r in d["revision_history"]],
            raw_texts=[TextLine(**t) for t in d["raw_texts"]],
            notes=d.get("notes", []),  # 구버전 저장분(notes 없음) 호환
        )


# ------------------------------------------------------------ 깨진 인코딩 정화
# 한국 CAD 실무 파일에는 코드페이지 정보가 틀려 cp949 원시 바이트가 서로게이트
# (\udc80~\udcff)로 섞여 나오는 텍스트가 흔하다. 이런 문자열은 sqlite 저장과
# utf-8 파일 쓰기를 UnicodeEncodeError로 죽여 검사 전체가 롤백되는 영구 실패가
# 된다 (2026-08-27 회사 서버 실파일에서 확인). 모든 추출 결과는 extract() 관문에서
# 이 정화를 거친다 — 복구 우선(cp949 재해석), 불가하면 대체 문자.

def _is_broken(ch: str) -> bool:
    return 0xD800 <= ord(ch) <= 0xDFFF


_NONASCII_RUN = re.compile(r"[^\x00-\x7f]+")


def clean_text(s: str) -> str:
    """서로게이트가 섞인 문자열을 저장 가능한 형태로. 정상 문자열은 그대로.

    cp949 바이트를 utf-8+surrogateescape로 읽으면 일부 바이트쌍이 우연히 유효한
    UTF-8로 디코딩돼 '가짜 문자 + 서로게이트'가 섞인다. 그래서 복구는 문자 단위가
    아니라 비ASCII 연속 구간 단위로: 서로게이트가 낀 구간 전체를 원시 바이트로
    되돌려 cp949로 재해석한다. 서로게이트 없는 구간(진짜 한글)은 손대지 않는다."""
    try:
        s.encode("utf-8")
        return s
    except UnicodeEncodeError:
        pass

    def fix_run(m: re.Match) -> str:
        run = m.group(0)
        try:
            run.encode("utf-8")
            return run                       # 정상 구간 보존
        except UnicodeEncodeError:
            pass
        try:                                 # 원시 바이트 복원 → cp949 재해석
            return run.encode("utf-8", "surrogateescape").decode("cp949")
        except (UnicodeEncodeError, UnicodeDecodeError):
            # 복구 불가(상위 서로게이트·타 인코딩): 깨진 문자만 대체 기호로
            return "".join("�" if _is_broken(c) else c for c in run)

    return _NONASCII_RUN.sub(fix_run, s)


def clean_obj(o):
    """dict/list 속 모든 문자열에 clean_text 적용 (키 포함)."""
    if isinstance(o, str):
        return clean_text(o)
    if isinstance(o, list):
        return [clean_obj(x) for x in o]
    if isinstance(o, dict):
        return {clean_obj(k): clean_obj(v) for k, v in o.items()}
    return o


def _has_surrogate(o) -> bool:
    if isinstance(o, str):
        return any(_is_broken(c) for c in o)
    if isinstance(o, list):
        return any(_has_surrogate(x) for x in o)
    if isinstance(o, dict):
        return any(_has_surrogate(k) or _has_surrogate(v) for k, v in o.items())
    return False
