# -*- coding: utf-8 -*-
"""루트 순회 → 산출물 폴더 자동 탐지 → 파일별 sha256.

읽기 전용 원칙: 이 모듈은 산출물 파일을 'rb'로만 연다.
쓰기·이동·삭제 API를 절대 사용하지 않는다 (tests/test_readonly.py가 강제).
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

# 산출물 확장자 기본값. 파일럿은 도면·BOM 중심이지만 문서 유형 독립 원칙에 따라
# 확장자 목록은 config로 교체 가능하다.
DEFAULT_EXTENSIONS = (".dwg", ".dxf", ".pdf", ".xlsx", ".docx")

# CAD·오피스 임시파일 소음 제거 (DECISIONS 2026-08-20: 하루 1회 배치 이유이기도 함)
SKIP_NAME_PREFIXES = ("~$", ".")
SKIP_SUFFIXES = (".bak", ".tmp", ".dwl", ".dwl2", ".sv$")

_HASH_CHUNK = 1024 * 1024

# 파일명 끝의 날짜/버전 표기: _20260815, _260309(YYMMDD), -2026.08.15, v2, rev3,
# (20260815), 최종, 수정2, (2차수정), 승인용, R1, IFA ... — 현장에서 리비전을
# 덮어쓰지 않고 '표기 붙인 새 파일'로 저장하는 문화 대응 (2026-08-26 설계 검토 보강).
# 6자리는 월(01-12)·일(01-31)이 유효할 때만 날짜로 본다(부품번호 오탐 방지).
# rev 뒤는 숫자 또는 '문자 하나+숫자'만 — REVIEW/REVISED 꼬리 오인 방지 (2026-08-25)
_VER_TOKEN = re.compile(
    r"[\s\-_.(]*(?:20\d{6}"
    r"|\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])"
    r"|20\d{2}[-._]\d{1,2}[-._]\d{1,2}"
    r"|\d{2}[-._](?:0?[1-9]|1[0-2])[-._](?:0?[1-9]|[12]\d|3[01])"
    r"|[vV]\d+|[rR][eE][vV][\s\-_.]?(?:\d{1,4}[A-Za-z]?|[A-Za-z]\d{0,3})"
    r"|[rR]\d{1,2}"
    r"|\d*차?\s*(?:수정|개정)(?:본|판)?\d*"
    r"|(?:진짜|찐)?\s*최종본?"
    r"|검토용|승인용|배포용|공람용|시공용|발행용|제출용"
    r"|IFA|IFC|AFC|IFR"
    r"|[fF]inal)\)?\s*$")


def revision_stem(filename: str) -> str:
    """파일명에서 확장자와 끝의 날짜/버전 표기를 걷어낸 '도면 이름'.
    예: '판넬도면_rev2_20260815.dxf' → '판넬도면' — 같은 stem이면 같은 도면의 다른 판."""
    stem = filename.rsplit(".", 1)[0] if "." in filename else filename
    prev = None
    while prev != stem:
        prev = stem
        stem = _VER_TOKEN.sub("", stem)
        # '(2차수정)'을 반쯤 벗기다 만 '이름(2차' 꼴 — 짝 잃은 여는 괄호 꼬리 수습
        stem = re.sub(r"\([^)]*$", "", stem)
    return stem.strip(" -_.")


def pair_key(filename: str) -> str:
    """판 짝짓기용 비교 키 — revision_stem에서 공백을 걷고 대소문자를 무시한다.
    재저장하며 '판넬도면'↔'판넬 도면', 'PANEL'↔'panel'처럼 달라진 판을 같은
    도면으로 본다 (2026-08-28 사용자 제안 — 같은 폴더 조건이 있어 위험이 낮다).
    밑줄·하이픈은 걷지 않는다: M-101과 M101은 도면번호 체계상 다를 수 있다."""
    return re.sub(r"\s+", "", revision_stem(filename)).casefold()


@dataclass(frozen=True)
class FileRecord:
    """스캔 시점의 파일 한 개 스냅샷. path는 루트 기준 상대 경로(POSIX 구분자)."""
    path: str
    sha256: str
    size: int
    mtime: float


def is_artifact(path: Path, extensions: Iterable[str] = DEFAULT_EXTENSIONS) -> bool:
    name = path.name
    if any(name.startswith(p) for p in SKIP_NAME_PREFIXES):
        return False
    if any(name.lower().endswith(s) for s in SKIP_SUFFIXES):
        return False
    return path.suffix.lower() in {e.lower() for e in extensions}


def find_artifact_dirs(root: Path, extensions: Iterable[str] = DEFAULT_EXTENSIONS) -> list[Path]:
    """산출물 파일을 1개 이상 직접 포함한 폴더 목록 (루트 기준 정렬)."""
    dirs: set[Path] = set()
    for p in Path(root).rglob("*"):
        if p.is_file() and is_artifact(p, extensions):
            dirs.add(p.parent)
    return sorted(dirs)


def sha256_of(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:  # 읽기 전용: 'rb' 외의 모드 금지
        while chunk := f.read(_HASH_CHUNK):
            h.update(chunk)
    return h.hexdigest()


def is_excluded(rel_posix: str, excludes: Iterable[str]) -> bool:
    """루트 기준 상대 경로가 제외 폴더 아래인지 (GUI '감시 제외' 설정)."""
    for ex in excludes:
        ex = ex.strip("/").strip()
        if ex and (rel_posix == ex or rel_posix.startswith(ex + "/")):
            return True
    return False


def scan(root: Path, extensions: Iterable[str] = DEFAULT_EXTENSIONS,
         errors: list | None = None,
         excludes: Iterable[str] = (),
         prev: dict | None = None,
         progress=None) -> list[FileRecord]:
    """루트 아래 모든 산출물 파일의 스냅샷. 경로 기준 정렬로 결과 재현 가능.

    읽지 못한 파일(잠김·권한)은 스캔을 중단시키지 않고 errors에 (경로, 사유)로
    수집된다 — 파일 1개 때문에 전체 감시가 죽으면 안 된다.
    excludes: 감시에서 뺄 루트 기준 상대 폴더 경로 목록.
    prev: 직전 스냅샷 {상대경로: (sha256, size, mtime)}. 크기·수정시각이 같으면
    파일을 다시 읽지 않고 저장된 해시를 재사용한다 — 수천 파일 서버에서 매일
    전체를 재해싱하면 시간 단위가 걸리므로. 저장이 일어나면 mtime이 반드시
    바뀌기 때문에 감지 정확도는 그대로다.
    """
    root = Path(root)
    records: list[FileRecord] = []
    for p in sorted(root.rglob("*")):
        if not (p.is_file() and is_artifact(p, extensions)):
            continue
        rel = p.relative_to(root).as_posix()
        if is_excluded(rel, excludes):
            continue
        try:
            st = p.stat()
            pv = prev.get(rel) if prev else None
            if pv is not None and pv[1] == st.st_size and pv[2] == st.st_mtime:
                sha = pv[0]  # 변동 없음 → 재해싱 생략
            else:
                sha = sha256_of(p)
        except OSError as e:
            if errors is not None:
                errors.append((rel, str(e)))
            continue
        records.append(FileRecord(path=rel, sha256=sha, size=st.st_size,
                                  mtime=st.st_mtime))
        if progress is not None:
            progress(len(records))  # 대용량 서버 첫 해싱이 '멈춘 것'처럼 보이지 않게
    return records
