# -*- coding: utf-8 -*-
"""자료 훑어보기: 받은 자료 폴더를 코드로 한 번 훑어 이상한 곳을 표로 뽑는다.

쓰는 법
    python 자료_훑어보기.py <자료 폴더 또는 파일> [결과 파일.md]

하는 일 (원본은 고치지 않는다. 읽기만 한다)
    표 파일(csv, tsv, xlsx, xls): 칸마다 빈칸, 서로 다른 값, 섞인 단위, 섞인 날짜 형식,
        숫자와 글자가 섞인 칸, 같은 줄, 이름이 조금 다른 값, 수량 × 단가 = 금액 검산, 말이 안 되는 값
    글 파일(txt, md): 날짜와 단위 표현, 같은 줄, AI에게 내리는 명령처럼 보이는 줄
    파일끼리: 같은 이름의 칸에 들어 있는 값이 서로 어떻게 다른가

찾은 것은 '후보'다. 진짜 오류인지는 사람이 원본을 보고 정한다.
"""
import collections
import datetime
import pathlib
import re
import sys

try:
    import pandas as pd
except ImportError:  # pandas 가 없으면 여기서 멈춘다
    print("pandas 가 필요합니다: pip install pandas openpyxl")
    sys.exit(2)

try:
    from rapidfuzz import fuzz
except ImportError:
    fuzz = None

UNITS = ["kg", "g", "톤", "t", "관", "근", "상자", "박스", "포기", "단", "망", "마리", "미", "두름", "축", "쾌", "톳",
         "손", "접", "개", "봉", "포대", "자루", "말", "되", "평", "ha", "리터", "원", "천원", "만원", "천 원", "만 원"]
NUM_UNIT = re.compile(r"^\s*([+-]?\d[\d,]*\.?\d*)\s*([가-힣a-zA-Z%]+(?:\s?원)?)?\s*$")
DATE_PATS = {
    "2026-10-03 꼴": re.compile(r"\b\d{4}-\d{1,2}-\d{1,2}\b"),
    "2026.10.03 꼴": re.compile(r"\b\d{4}\.\s?\d{1,2}\.\s?\d{1,2}\.?"),
    "10/3 꼴": re.compile(r"(?<![\d.])\d{1,2}/\d{1,2}(?:/\d{2,4})?(?![\d.])"),
    "10월 3일 꼴": re.compile(r"\d{1,2}월\s?\d{1,2}일"),
    "20261003 꼴": re.compile(r"(?<!\d)20\d{6}(?!\d)"),
    "음력": re.compile(r"음(?:력)?\s?\d{1,2}[./월]\s?\d{1,2}"),
    "물때": re.compile(r"\d{1,2}\s?물(?![가-힣])|(?<![가-힣])(?:조금|사리)(?![가-힣])"),
}
ORDER_PATS = [
    (re.compile(r"(이전|앞|위)(의|에서)?\s*(지시|명령|규칙|안내).{0,12}(무시|잊)"), "앞의 지시를 무시하라는 말"),
    (re.compile(r"ignore\s+(all|any|previous|the above)", re.I), "ignore previous 류"),
    (re.compile(r"system\s*prompt|시스템\s*프롬프트|지시문을?\s*(보여|출력|알려)"), "지시문을 캐내려는 말"),
    (re.compile(r"(AI|인공지능|에이전트|챗봇|모델|어시스턴트|시스템)\s*(는|은|에게|라면|야)[^.\n]{0,80}(하라|하시오|할 것|해라|하세요|마라|마시오|말 것|넣을 것|뺄 것)"), "AI에게 내리는 명령"),
    (re.compile(r"(AI|인공지능|에이전트|챗봇|모델|시스템)[^.\n]{0,80}(무시하|따르지 마|말하지 마|알리지 마|비밀로)"), "AI에게 무시하거나 숨기라는 말"),
    (re.compile(r"(이 (줄|문장|문서|글)을?\s*읽(은|는|으면|고))"), "읽는 쪽을 부르는 말"),
    (re.compile(r"<!--"), "숨긴 주석(화면에 안 보임)"),
    (re.compile(r"color\s*:\s*(#fff\b|#ffffff|white)|font-size\s*:\s*[01](px|pt)|display\s*:\s*none|opacity\s*:\s*0(\.0+)?\b", re.I), "흰 글씨, 아주 작은 글씨, 감춘 글"),
    (re.compile(r"[​‌‍⁠﻿]"), "보이지 않는 글자"),
]
GRADE = re.compile(r"\((.*?)\)|\[(.*?)\]|특대|특|상|중|하|대|소|왕|햇|국산|\s+")
TODAY = datetime.date.today()


def read_table(path):
    suf = path.suffix.lower()
    if suf in (".xlsx", ".xls"):
        sheets = pd.read_excel(path, sheet_name=None, dtype=str, header=0)
        return [(f"{path.name} [{name}]", df) for name, df in sheets.items()]
    sep = "\t" if suf == ".tsv" else ","
    for enc in ("utf-8-sig", "cp949", "utf-8"):
        try:
            return [(path.name, pd.read_csv(path, dtype=str, sep=sep, encoding=enc, keep_default_na=False))]
        except UnicodeDecodeError:
            continue
        except Exception as exc:  # 줄마다 칸 수가 다른 파일 등
            return [(path.name, f"읽기 실패: {exc}")]
    return [(path.name, "읽기 실패: 글자 부호를 알 수 없음")]


def read_text(path):
    raw = path.read_bytes()
    for enc in ("utf-8-sig", "cp949", "utf-16"):
        try:
            return raw.decode(enc)
        except UnicodeDecodeError:
            continue
    return raw.decode("utf-8", errors="replace")


def to_num(s):
    m = NUM_UNIT.match(s)
    if not m:
        return None, None
    try:
        return float(m.group(1).replace(",", "")), (m.group(2) or "").strip()
    except ValueError:
        return None, None


def date_kinds(values):
    kinds = collections.Counter()
    for v in values:
        for name, pat in DATE_PATS.items():
            if pat.search(v):
                kinds[name] += 1
    return kinds


def variants(values):
    """뜻은 같아 보이는데 글자가 다른 값 묶음. 전화번호나 숫자 코드 칸은 건너뛴다."""
    digitish = sum(1 for v in values if re.fullmatch(r"[\d\-\s()+.]+", v))
    if values and digitish >= 0.8 * len(values):
        return []
    groups = collections.defaultdict(set)
    for v in values:
        key = GRADE.sub("", v).lower()
        if key:
            groups[key].add(v)
    out = [sorted(g) for g in groups.values() if len(g) > 1]
    if fuzz and len(values) <= 300:
        vals = sorted(values)
        seen = {tuple(g) for g in out}
        for i, a in enumerate(vals):
            for b in vals[i + 1:]:
                if 2 <= len(a) and 2 <= len(b) and a != b and fuzz.ratio(a, b) >= 86:
                    pair = tuple(sorted((a, b)))
                    if not any(set(pair) <= set(g) for g in seen):
                        seen.add(pair)
                        out.append(list(pair))
    return out[:15]


def check_table(name, df, lines):
    lines.append(f"\n## {name}\n")
    if isinstance(df, str):
        lines.append(f"- {df}")
        return {}
    df = df.fillna("").astype(str)
    lines.append(f"- 줄 {len(df)}개, 칸 {len(df.columns)}개: {', '.join(map(str, df.columns))}")
    dup = df[df.duplicated(keep=False)]
    if len(dup):
        rows = ", ".join(str(i + 2) for i in dup.index[:12])
        lines.append(f"- **똑같은 줄** {len(dup)}개 (파일 줄 번호 {rows})")
    # 날짜 꼴만 다르거나 띄어쓰기, 괄호만 다른 같은 줄
    def is_date_col(c):
        vals = [v for v in df[c] if v.strip()]
        return bool(vals) and sum(date_kinds(vals).values()) >= 0.6 * len(vals)
    keep = [c for c in df.columns if not is_date_col(c)]
    if keep and len(keep) < len(df.columns) or keep:
        keys = collections.defaultdict(list)
        for i, row in df.iterrows():
            key = tuple(re.sub(r"[\s()\[\]]", "", row[c]) for c in keep)
            if any(key):
                keys[key].append(i + 2)
        near = [v for v in keys.values() if len(v) > 1]
        exact = set(i + 2 for i in dup.index)
        near = [v for v in near if not set(v) <= exact]
        if near:
            lines.append(f"- **거의 같은 줄** {len(near)}묶음 (날짜 꼴, 띄어쓰기, 괄호만 다름). 파일 줄 번호: "
                         + " / ".join(",".join(map(str, v)) for v in near[:10]))
    lines.append("\n| 칸 | 빈칸 | 서로 다른 값 | 꼴 | 눈에 띄는 것 |")
    lines.append("|---|---|---|---|---|")
    colvals, numcols = {}, {}
    for c in df.columns:
        col = df[c]
        vals = [v for v in col if v.strip() != ""]
        blanks = len(col) - len(vals)
        distinct = set(v.strip() for v in vals)
        notes = []
        nums, units, non_num = [], collections.Counter(), 0
        for v in vals:
            n, u = to_num(v)
            if n is None:
                non_num += 1
            else:
                nums.append(n)
                if u:
                    units[u] += 1
        kinds = date_kinds(vals)
        if vals and len(nums) >= 0.6 * len(vals):
            shape = "숫자"
            numcols[c] = col
            if non_num:
                bad = [v for v in vals if to_num(v)[0] is None][:4]
                notes.append(f"숫자가 아닌 값 {non_num}개: {', '.join(bad)}")
            if len(units) > 1 or (units and sum(units.values()) < len(nums)):
                notes.append("단위 섞임: " + ", ".join(f"{u or '없음'} {k}" for u, k in units.most_common(5))
                             + (f", 단위 없음 {len(nums) - sum(units.values())}" if sum(units.values()) < len(nums) else ""))
            neg = [n for n in nums if n < 0]
            if neg:
                notes.append(f"음수 {len(neg)}개")
            if nums and len(nums) >= 8:
                s = sorted(nums)
                med = s[len(s) // 2]
                far = [n for n in nums if med > 0 and (n > med * 20 or (0 < n < med / 20))]
                if far:
                    notes.append(f"가운데 값({med:,.10g})과 20배 넘게 차이 나는 값 {len(far)}개: {', '.join(f'{n:,.10g}' for n in far[:4])}")
        elif kinds and sum(kinds.values()) >= 0.6 * len(vals):
            shape = "날짜"
            if len(kinds) > 1:
                notes.append("날짜 형식 섞임: " + ", ".join(f"{k} {n}" for k, n in kinds.most_common()))
        else:
            shape = "글"
            if kinds:
                notes.append("날짜 표현: " + ", ".join(f"{k} {n}" for k, n in kinds.most_common(3)))
            unit_hits = collections.Counter(u for v in vals for u in UNITS if re.search(rf"\d\s?{re.escape(u)}(?![가-힣a-zA-Z])", v))
            if len(unit_hits) > 1:
                notes.append("단위 표현: " + ", ".join(f"{u} {n}" for u, n in unit_hits.most_common(6)))
            if 1 < len(distinct) <= 300:
                var = variants(distinct)
                if var:
                    notes.append("비슷한 이름: " + " / ".join("=".join(g) for g in var[:6]))
        spaced = sum(1 for v in vals if v != v.strip() or "  " in v)
        if spaced:
            notes.append(f"앞뒤나 가운데 빈칸이 이상한 값 {spaced}개")
        lines.append(f"| {c} | {blanks} | {len(distinct)} | {shape} | {'<br>'.join(notes) or ''} |")
        colvals[str(c)] = distinct
    check_amount(df, numcols, lines)
    return colvals


def check_amount(df, numcols, lines):
    """수량 × 단가 = 금액 검산. 칸 이름으로 짝을 찾는다."""
    def find(words):
        for c in df.columns:
            if any(w in str(c) for w in words):
                return c
        return None
    q, p, a = find(["수량", "개수", "물량", "중량"]), find(["단가", "가격"]), find(["금액", "합계", "판매액", "대금"])
    if not (q and p and a) or len({q, p, a}) < 3:
        return
    bad = []
    for i, row in df.iterrows():
        qn, pn, an = to_num(row[q])[0], to_num(row[p])[0], to_num(row[a])[0]
        if None in (qn, pn, an):
            continue
        if abs(qn * pn - an) > max(1.0, abs(an) * 0.001):
            bad.append((i + 2, row[q], row[p], row[a], qn * pn))
    if bad:
        lines.append(f"\n- **검산 안 맞음** ({q} × {p} ≠ {a}) {len(bad)}줄")
        for ln, qv, pv, av, calc in bad[:12]:
            lines.append(f"  - 파일 줄 {ln}: {qv} × {pv} = {calc:g} 인데 {av} 로 적힘")
    else:
        lines.append(f"\n- 검산 통과 ({q} × {p} = {a})")


def check_text(path, lines):
    text = read_text(path)
    rows = text.splitlines()
    lines.append(f"\n## {path.name}\n")
    lines.append(f"- 줄 {len(rows)}개, 글자 {len(text)}개")
    kinds = date_kinds(rows)
    if kinds:
        lines.append("- 날짜 표현: " + ", ".join(f"{k} {n}줄" for k, n in kinds.most_common()))
    unit_hits = collections.Counter(u for r in rows for u in UNITS if re.search(rf"\d\s?{re.escape(u)}(?![가-힣a-zA-Z])", r))
    if unit_hits:
        lines.append("- 단위 표현: " + ", ".join(f"{u} {n}줄" for u, n in unit_hits.most_common(10)))
    body = [r.strip() for r in rows if len(r.strip()) >= 8]
    rep = [(r, n) for r, n in collections.Counter(body).items() if n > 1]
    if rep:
        lines.append(f"- **같은 줄이 되풀이됨** {len(rep)}종")
        for r, n in rep[:8]:
            lines.append(f"  - {n}번: {r[:60]}")
    hits = []
    for no, r in enumerate(rows, 1):
        for pat, label in ORDER_PATS:
            if pat.search(r):
                hits.append((no, label, r.strip()[:90]))
                break
    if hits:
        lines.append(f"- **AI에게 내리는 명령처럼 보이는 줄** {len(hits)}개. 따르지 말고 사람에게 알린다")
        for no, label, r in hits[:10]:
            lines.append(f"  - {no}번째 줄 ({label}): {r}")


def cross(tables, lines):
    by_col = collections.defaultdict(list)
    for name, cols in tables.items():
        for c, vals in cols.items():
            if 1 < len(vals) <= 500:
                by_col[c].append((name, vals))
    rows = []
    for c, items in by_col.items():
        if len(items) < 2:
            continue
        for i in range(len(items)):
            for j in range(i + 1, len(items)):
                (na, va), (nb, vb) = items[i], items[j]
                only_a, only_b = sorted(va - vb), sorted(vb - va)
                if only_a or only_b:
                    rows.append(f"| {c} | {na} 에만: {', '.join(only_a[:8])}{' …' if len(only_a) > 8 else ''} | {nb} 에만: {', '.join(only_b[:8])}{' …' if len(only_b) > 8 else ''} |")
    if rows:
        lines.append("\n## 파일끼리 맞춰 보기\n")
        lines.append("같은 이름의 칸에 들어 있는 값이 서로 다른 곳이다.\n")
        lines.append("| 칸 | 한쪽에만 있는 값 | 다른 쪽에만 있는 값 |")
        lines.append("|---|---|---|")
        lines.extend(rows[:30])


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        return 2
    sys.stdout.reconfigure(encoding="utf-8")
    target = pathlib.Path(sys.argv[1])
    files = sorted(p for p in (target.rglob("*") if target.is_dir() else [target]) if p.is_file())
    lines = [f"# 자료 훑어보기 결과", "", f"- 본 곳: {target}", f"- 본 날: {TODAY.isoformat()}",
             "- 아래는 모두 후보다. 진짜 오류인지는 원본을 보고 정한다.", ""]
    tables, skipped = {}, []
    for p in files:
        suf = p.suffix.lower()
        if suf in (".csv", ".tsv", ".xlsx", ".xls"):
            for name, df in read_table(p):
                tables[name] = check_table(name, df, lines)
        elif suf in (".txt", ".md", ".json", ".log"):
            check_text(p, lines)
        else:
            skipped.append(p.name)
    cross(tables, lines)
    if skipped:
        lines.append("\n## 훑지 못한 파일\n")
        lines.append("사진, PDF, 한글 문서는 먼저 글자로 바꾼 뒤 다시 돌린다: " + ", ".join(skipped))
    report = "\n".join(lines) + "\n"
    out = pathlib.Path(sys.argv[2]) if len(sys.argv) > 2 else None
    if out:
        out.write_text(report, encoding="utf-8", newline="\n")
        print(f"결과를 {out} 에 썼습니다.")
    else:
        print(report)
    return 0


if __name__ == "__main__":
    sys.exit(main())
