#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
学科网资料时间分类程序。

按资料文件本身和压缩包内最新子文件的时间关系，把学科网资料分为四类。
"""

import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple

BASE_DIR = Path(__file__).resolve().parent
ARCHIVE_EXTENSIONS = {".zip", ".rar", ".7z"}
FIVE_MINUTES_SECONDS = 5 * 60
DB_CONFIG = {
    "host": os.environ.get("XKW_DB_HOST", "192.168.0.164"),
    "port": int(os.environ.get("XKW_DB_PORT", "3307")),
    "user": os.environ.get("XKW_DB_USER", "root"),
    "password": os.environ.get("XKW_DB_PASSWORD", "myP#ssw0rd"),
    "database": os.environ.get("XKW_DB_NAME", "xkw"),
    "charset": "utf8mb4",
}
EVIDENCE_TABLE_NAME = os.environ.get("XKW_EVIDENCE_TABLE", "证据10总表")

SINGLE_FILE = "single_file"
ARCHIVE_WITHIN_5_MINUTES = "archive_within_5_minutes"
ARCHIVE_CHILD_LATER = "archive_child_later"
ARCHIVE_CHILD_EARLIER = "archive_child_earlier"
ARCHIVE_REQUIRES_SOURCE_TIME = "archive_requires_source_time"
UNSUPPORTED_ARCHIVE = "unsupported_archive"
EMPTY_ARCHIVE = "empty_archive"
FAILED_ARCHIVE = "failed_archive"

CLASSIFICATION_CATEGORIES = (
    SINGLE_FILE,
    ARCHIVE_WITHIN_5_MINUTES,
    ARCHIVE_CHILD_LATER,
    ARCHIVE_CHILD_EARLIER,
)

MATCH_KEYWORDS = [
    "语文", "数学", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "科学",
    "中考", "高考", "会考", "学考", "一模", "二模", "三模", "模拟", "月考", "期中",
    "期末", "联考", "调研", "测试", "试卷", "试题", "课件", "导学案", "教案",
]


def format_datetime(ts: Optional[float]) -> Optional[str]:
    if ts is None:
        return None
    return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")


def clear_directory(dir_path: Path) -> None:
    if not dir_path.exists():
        return
    for item in dir_path.iterdir():
        if item.is_dir():
            shutil.rmtree(item)
        else:
            item.unlink()


def is_archive(path: Path) -> bool:
    return path.suffix.lower() in ARCHIVE_EXTENSIONS


def build_file_record(path: Path, source_root: Optional[Path] = None) -> Dict:
    stat_info = path.stat()
    record = {
        "file_name": path.name,
        "path": str(path.resolve()),
        "mtime": stat_info.st_mtime,
        "datetime": format_datetime(stat_info.st_mtime),
    }
    if source_root:
        try:
            record["relative_path"] = str(path.resolve().relative_to(source_root.resolve()))
        except ValueError:
            record["relative_path"] = path.name
    return record


def extract_zip_with_member_times(zip_path: Path, extract_dir: Path) -> List[Path]:
    clear_directory(extract_dir)
    extract_dir.mkdir(parents=True, exist_ok=True)
    extracted_files: List[Path] = []

    with zipfile.ZipFile(zip_path, "r") as zip_file:
        for member in zip_file.infolist():
            extracted_path = zip_file.extract(member, extract_dir)
            member_path = Path(extracted_path)
            if not member_path.exists():
                continue

            timestamp = datetime(*member.date_time[:6]).timestamp()
            os.utime(member_path, (timestamp, timestamp))
            if member_path.is_file():
                extracted_files.append(member_path)

    return extracted_files


def collect_files(base_dir: Path) -> List[Path]:
    return sorted((path for path in base_dir.rglob("*") if path.is_file()), key=lambda path: str(path).lower())


def extract_external_archive(archive_path: Path, extract_dir: Path) -> List[Path]:
    clear_directory(extract_dir)
    extract_dir.mkdir(parents=True, exist_ok=True)

    seven_zip = shutil.which("7z")
    if not seven_zip:
        raise RuntimeError(f"需要安装 7z 才能解压 {archive_path.suffix} 文件")

    result = subprocess.run(
        [seven_zip, "x", "-y", f"-o{extract_dir}", str(archive_path)],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        error_text = (result.stderr or result.stdout or "").strip()
        raise RuntimeError(error_text or f"7z 解压失败：{archive_path.name}")

    return collect_files(extract_dir)


def extract_archive(archive_path: Path, extract_dir: Path) -> List[Path]:
    if archive_path.suffix.lower() == ".zip":
        return extract_zip_with_member_times(archive_path, extract_dir)
    return extract_external_archive(archive_path, extract_dir)


def latest_child_mtime_from_files(files: List[Path]) -> Optional[float]:
    child_times = [file_path.stat().st_mtime for file_path in files if file_path.is_file()]
    return max(child_times) if child_times else None


def preliminary_archive_category(archive_mtime: float, child_mtime: float) -> str:
    diff_seconds = child_mtime - archive_mtime
    if abs(diff_seconds) <= FIVE_MINUTES_SECONDS:
        return ARCHIVE_WITHIN_5_MINUTES
    return ARCHIVE_REQUIRES_SOURCE_TIME


def parse_database_datetime(value) -> Tuple[Optional[str], Optional[float]]:
    if value in (None, ""):
        return None, None
    if isinstance(value, datetime):
        dt = value
        return dt.strftime("%Y-%m-%d %H:%M:%S"), dt.timestamp()

    text = str(value).strip()
    formats = (
        "%d/%m/%y %H:%M:%S",
        "%d/%m/%y %H:%M",
        "%d/%m/%Y %H:%M:%S",
        "%d/%m/%Y %H:%M",
        "%Y/%m/%d %H:%M:%S",
        "%Y/%m/%d %H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
        "%Y/%m/%d",
        "%Y-%m-%d",
    )
    for fmt in formats:
        try:
            dt = datetime.strptime(text, fmt)
            return dt.strftime("%Y-%m-%d %H:%M:%S"), dt.timestamp()
        except ValueError:
            continue
    return text, None


def normalize_for_match(text: str) -> str:
    text = str(text).lower()
    text = re.sub(r"[\u0020-\u002f\u003a-\u0040\u005b-\u0060\u007b-\u007e]+", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def tokenize_for_match(text: str) -> Dict[str, float]:
    normalized = normalize_for_match(text)
    compact = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", normalized)
    tokens: Dict[str, float] = {}

    def add_token(token: str, weight: float) -> None:
        token = re.sub(r"^[年上下前后新旧本]+", "", token)
        if token:
            tokens[token] = max(tokens.get(token, 0.0), weight)

    for keyword in MATCH_KEYWORDS:
        if keyword in compact:
            add_token(keyword, 1.8)

    for number in re.findall(r"\d{4}|\d+", normalized):
        add_token(number, 0.6 if len(number) == 4 else 0.3)

    for alpha in re.findall(r"[a-z]+", normalized):
        add_token(alpha, 0.2)

    for chunk in re.findall(r"[\u4e00-\u9fff]+", compact):
        for n, weight in ((2, 0.25), (3, 0.7), (4, 1.1)):
            if len(chunk) < n:
                continue
            for index in range(len(chunk) - n + 1):
                add_token(chunk[index:index + n], weight)

    return tokens


def token_overlap_score(a: str, b: str) -> float:
    tokens_a = tokenize_for_match(a)
    tokens_b = tokenize_for_match(b)
    if not tokens_a or not tokens_b:
        return 0.0
    overlap_weight = sum(min(weight, tokens_b.get(token, 0.0)) for token, weight in tokens_a.items() if token in tokens_b)
    total_weight = sum(tokens_a.values())
    return overlap_weight / total_weight if total_weight else 0.0


def parse_word_content_lines(raw_lines: List[str]) -> List[str]:
    def normalize_line(line: str) -> str:
        return line.replace("\ufeff", "").strip()

    def is_url_line(line: str) -> bool:
        return bool(re.match(r"^https?://", normalize_line(line)))

    def is_index_line(line: str) -> bool:
        return bool(re.match(r"^\d+[\.、]?$", normalize_line(line)))

    def is_twole_label(line: str) -> bool:
        return bool(re.match(r"^(二一教育|21 世纪教育|21 世纪教育网|二一世纪教育)\s*[:：]?$", normalize_line(line)))

    def is_zxxk_label(line: str) -> bool:
        return bool(re.match(r"^学科网\s*[:：]?$", normalize_line(line)))

    def is_date_time(line: str) -> bool:
        pattern = r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}(\s+\d{1,2}:\d{1,2}(:\d{1,2})?(\s*(AM|PM|上午 | 下午))?)?$"
        return bool(re.match(pattern, normalize_line(line)))

    def is_skip_line(line: str) -> bool:
        return is_index_line(line) or is_twole_label(line) or is_zxxk_label(line) or not normalize_line(line)

    def is_url_fragment(line: str) -> bool:
        return bool(re.match(r"^[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+$", normalize_line(line)))

    def consume_url(lines: List[str], start: int) -> Optional[Tuple[str, int]]:
        i = start
        while i < len(lines) and is_skip_line(lines[i]):
            i += 1
        if i >= len(lines):
            return None
        first_part = normalize_line(lines[i])
        if not first_part.startswith("http"):
            return None
        url_parts = [first_part]
        i += 1
        while i < len(lines):
            line = normalize_line(lines[i])
            if is_date_time(line):
                break
            if is_skip_line(line):
                i += 1
                continue
            if not is_url_fragment(line):
                break
            url_parts.append(line)
            i += 1
        return "".join(url_parts), i

    def parse_content_item(lines: List[str], start: int) -> Optional[Tuple[str, str, str, int]]:
        i = start
        while i < len(lines) and is_skip_line(lines[i]):
            i += 1
        title_parts = []
        while i < len(lines) and len(title_parts) < 8:
            line = normalize_line(lines[i])
            if is_url_line(line):
                break
            if is_date_time(line):
                return None
            if is_skip_line(line):
                i += 1
                continue
            title_parts.append(line)
            i += 1
        while i < len(lines) and is_skip_line(lines[i]):
            i += 1
        url_result = consume_url(lines, i)
        if not title_parts or not url_result:
            return None
        title = "".join(title_parts)
        url, i = url_result
        date = "1970-1-1"
        if i < len(lines) and is_date_time(lines[i]):
            date = normalize_line(lines[i])
            i += 1
        return title, url, date, i

    def get_site_from_url(url: str) -> Optional[str]:
        normalized_url = normalize_line(url).lower()
        if "21cnjy.com" in normalized_url:
            return "twole"
        if "zxxk.com" in normalized_url:
            return "zxxk"
        return None

    def extract_content_items(lines: List[str]) -> List[Tuple[str, str, str, str]]:
        items = []
        i = 0
        while i < len(lines):
            item = parse_content_item(lines, i)
            if not item:
                i += 1
                continue
            title, url, date, next_i = item
            site = get_site_from_url(url)
            if site in ("twole", "zxxk"):
                items.append((title, url, date, site))
            i = next_i
        return items

    def build_grouped_rows(items: List[Tuple[str, str, str, str]]) -> List[str]:
        twole_items = [(title, url, date) for title, url, date, site in items if site == "twole"]
        zxxk_items = [(title, url, date) for title, url, date, site in items if site == "zxxk"]
        count = min(len(twole_items), len(zxxk_items))
        return ["\t".join([twole_items[index][0], twole_items[index][1], twole_items[index][2],
                           zxxk_items[index][0], zxxk_items[index][1], zxxk_items[index][2]]) for index in range(count)]

    def parse_site_items(lines: List[str]) -> List[str]:
        items = extract_content_items(lines)
        sites = [site for _, _, _, site in items]
        if len(sites) < 2 or len(set(sites)) < 2:
            return []
        return build_grouped_rows(items)

    def parse_grouped_site_rows(lines: List[str]) -> List[str]:
        twole_items = []
        zxxk_items = []
        current_site = None
        i = 0
        while i < len(lines):
            line = normalize_line(lines[i])
            if is_twole_label(line):
                current_site = "twole"
                i += 1
                continue
            if is_zxxk_label(line):
                current_site = "zxxk"
                i += 1
                continue
            if not line or line in ("......", "……") or line.startswith("其它内容") or line.startswith("其他内容"):
                i += 1
                continue
            if current_site not in ("twole", "zxxk"):
                i += 1
                continue
            title = line
            url = ""
            date = "1970-1-1"
            url_result = consume_url(lines, i + 1)
            if url_result:
                url, i = url_result
                if i < len(lines) and is_date_time(lines[i]):
                    date = normalize_line(lines[i])
                    i += 1
            else:
                i += 1
            if current_site == "twole":
                twole_items.append((title, url, date))
            else:
                zxxk_items.append((title, url, date))
        count = min(len(twole_items), len(zxxk_items))
        return ["\t".join([twole_items[index][0], twole_items[index][1], twole_items[index][2],
                           zxxk_items[index][0], zxxk_items[index][1], zxxk_items[index][2]]) for index in range(count)]

    lines = [normalize_line(line) for line in raw_lines if normalize_line(line)]
    has_tab_in_first_rows = any("\t" in line for line in lines[:6])
    split_lines = []
    for line in lines:
        split_lines.extend(normalize_line(part) for part in line.split("\t") if normalize_line(part))

    site_result = parse_site_items(split_lines)
    if site_result:
        return site_result
    has_site_labels = any(is_twole_label(line) or is_zxxk_label(line) for line in split_lines)
    if has_site_labels:
        grouped_result = parse_grouped_site_rows(split_lines)
        if grouped_result:
            return grouped_result
    if has_tab_in_first_rows or len(split_lines) < 6:
        return lines
    return lines


def read_docx_content(doc_path: Path) -> List[str]:
    def clean_hyperlink(line: str) -> str:
        patterns = [r"HYPERLINK\s+&quot;([^&]+)&quot;\s+(\S+)", r"HYPERLINK\s+\"([^\"]+)\"\s+(\S+)"]
        for pattern in patterns:
            match = re.match(pattern, line)
            if match:
                return match.group(2)
        return line

    try:
        with zipfile.ZipFile(doc_path, "r") as z:
            content = z.read("word/document.xml").decode("utf-8")
            texts = re.findall(r"<w:t[^>]*>(.*?)</w:t>", content, re.DOTALL)
            processed = []
            for text in texts:
                text = re.sub(r"<[^>]+>", "", text).strip()
                if text:
                    processed.append(clean_hyperlink(text))
            return parse_word_content_lines(processed)
    except Exception:
        return []


def read_doc_content_old(doc_path: Path) -> List[str]:
    for soffice_cmd in ["/Applications/LibreOffice.app/Contents/MacOS/soffice",
                        "/Applications/LibreOffice.app/Contents/MacOS/libreoffice", "soffice"]:
        if os.path.isfile(soffice_cmd) and subprocess.run([soffice_cmd, "--version"], capture_output=True).returncode == 0:
            break
    else:
        return []

    import tempfile

    try:
        with tempfile.TemporaryDirectory() as tmpdir:
            result = subprocess.run(
                [soffice_cmd, "--headless", "--convert-to", "txt", "--outdir", tmpdir, str(doc_path)],
                capture_output=True,
                timeout=60,
            )
            if result.returncode != 0:
                return []
            txt_file = Path(tmpdir) / (doc_path.stem + ".txt")
            if txt_file.exists():
                lines = [line.strip() for line in txt_file.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()]
                return parse_word_content_lines(lines)
    except Exception:
        pass
    return []


def read_word_content(doc_path: Path) -> List[str]:
    if doc_path.suffix.lower() == ".docx":
        return read_docx_content(doc_path)
    if doc_path.suffix.lower() == ".doc":
        return read_doc_content_old(doc_path)
    return []


def find_word_doc_for_zxxk_dir(zxxk_dir: Path) -> Optional[Path]:
    parent = zxxk_dir.parent
    word_files = sorted(
        (item for item in parent.iterdir() if item.is_file() and item.suffix.lower() in (".docx", ".doc")),
        key=lambda item: item.name.lower(),
    )
    return word_files[0] if word_files else None


def extract_zxxk_id(url: str) -> Optional[str]:
    match = re.search(r"/(?:soft/)?(\d+)\.html(?:[?#].*)?$", url or "")
    if match:
        return match.group(1)
    numbers = re.findall(r"\d+", url or "")
    return numbers[-1] if numbers else None


def extract_zxxk_metadata_rows(word_doc: Optional[Path]) -> List[Dict]:
    if not word_doc:
        return []
    rows = []
    for row_index, content in enumerate(read_word_content(word_doc)):
        parts = [part.strip() for part in content.split("\t")]
        if len(parts) < 6:
            continue
        candidates = [(parts[0], parts[1], parts[2]), (parts[3], parts[4], parts[5])]
        for title, url, source_time in candidates:
            if "zxxk.com" not in url.lower():
                continue
            rows.append({
                "title": title,
                "url": url,
                "source_time": source_time,
                "word_source_time": source_time,
                "zxxk_id": extract_zxxk_id(url),
                "word_doc": word_doc.name,
                "word_doc_path": str(word_doc.resolve()),
                "word_row_index": row_index,
            })
    return rows


def match_metadata_for_file(material_path: Path, metadata_rows: List[Dict]) -> Optional[Dict]:
    if not metadata_rows:
        return None
    file_name = material_path.name
    file_stem = material_path.stem
    file_norm = normalize_for_match(file_stem)
    exact_matches = []
    for row in metadata_rows:
        title_norm = normalize_for_match(row["title"])
        if title_norm and (title_norm == file_norm or title_norm in file_norm or file_norm in title_norm):
            exact_matches.append(row)
    if exact_matches:
        return max(exact_matches, key=lambda row: len(normalize_for_match(row["title"])))

    scored_rows = [
        (token_overlap_score(row["title"], file_name), row)
        for row in metadata_rows
    ]
    best_score, best_row = max(scored_rows, key=lambda item: item[0])
    return best_row if best_score > 0 else None


def enrich_record_with_metadata(record: Dict, material_path: Path, metadata_rows: List[Dict]) -> None:
    metadata = match_metadata_for_file(material_path, metadata_rows)
    record["metadata_found"] = metadata is not None
    if not metadata:
        record.update({
            "title": None,
            "url": None,
            "source_time": None,
            "source_time_raw": None,
            "source_time_mtime": None,
            "word_source_time": None,
            "source_time_source": None,
            "classification_time_basis": None,
            "child_source_time_diff_seconds": None,
            "zxxk_id": None,
            "word_doc": None,
            "word_doc_path": None,
            "word_row_index": None,
        })
        return
    record.update(metadata)
    record["source_time_source"] = "word"


class MysqlUploadTimeLookup:
    def __init__(self, config: Optional[Dict] = None, table_name: str = EVIDENCE_TABLE_NAME):
        self.config = dict(config or DB_CONFIG)
        self.table_name = table_name
        self.conn = None
        self.cache: Dict[Tuple[str, str], Optional[str]] = {}

    def close(self) -> None:
        if self.conn:
            self.conn.close()
            self.conn = None

    def connect(self):
        if self.conn:
            return self.conn
        import mysql.connector

        self.conn = mysql.connector.connect(**self.config)
        return self.conn

    def __call__(self, metadata: Dict) -> Optional[str]:
        zxxk_id = metadata.get("zxxk_id") or ""
        url = metadata.get("url") or ""
        cache_key = (zxxk_id, url)
        if cache_key not in self.cache:
            self.cache[cache_key] = self.fetch_upload_time(zxxk_id, url)
        return self.cache[cache_key]

    def fetch_upload_time(self, zxxk_id: str, url: str) -> Optional[str]:
        if not zxxk_id and not url:
            return None

        conn = self.connect()
        cursor = conn.cursor()
        try:
            if zxxk_id:
                cursor.execute(
                    f"SELECT `学科网上传日期` FROM `{self.table_name}` "
                    "WHERE `xkwID` = %s AND `学科网上传日期` IS NOT NULL AND `学科网上传日期` <> '' LIMIT 1",
                    (zxxk_id,),
                )
                row = cursor.fetchone()
                if row and row[0]:
                    return str(row[0])

            if url:
                cursor.execute(
                    f"SELECT `学科网上传日期` FROM `{self.table_name}` "
                    "WHERE `学科网资料链接` = %s AND `学科网上传日期` IS NOT NULL AND `学科网上传日期` <> '' LIMIT 1",
                    (url,),
                )
                row = cursor.fetchone()
                if row and row[0]:
                    return str(row[0])
        finally:
            cursor.close()
        return None


def apply_upload_time_lookup(record: Dict, upload_time_lookup) -> None:
    if not record.get("metadata_found") or not upload_time_lookup:
        return
    upload_time = upload_time_lookup(record)
    if not upload_time:
        return
    normalized_time, timestamp = parse_database_datetime(upload_time)
    record["source_time_raw"] = str(upload_time)
    record["source_time"] = normalized_time
    record["source_time_mtime"] = timestamp
    record["source_time_source"] = "mysql"


def reclassify_archive_with_source_time(record: Dict) -> None:
    if record.get("file_kind") != "archive" or record.get("latest_child_mtime") is None:
        return

    source_time_mtime = record.get("source_time_mtime")
    if source_time_mtime is not None:
        record["child_source_time_diff_seconds"] = record["latest_child_mtime"] - source_time_mtime

    archive_diff_seconds = record.get("child_archive_time_diff_seconds")
    if archive_diff_seconds is not None and abs(archive_diff_seconds) <= FIVE_MINUTES_SECONDS:
        record["category"] = ARCHIVE_WITHIN_5_MINUTES
        record["classification_time_basis"] = "archive_mtime_within_5_minutes"
        return

    if source_time_mtime is None:
        record["category"] = ARCHIVE_REQUIRES_SOURCE_TIME
        record["classification_time_basis"] = "requires_mysql_upload_time"
        return

    diff_seconds = record["child_source_time_diff_seconds"]
    record["classification_time_basis"] = "mysql_upload_time"
    if diff_seconds > 6000:
        record["category"] = ARCHIVE_CHILD_LATER
    else:
        record["category"] = ARCHIVE_CHILD_EARLIER


def classify_material(material_path: Path, work_dir: Path, source_root: Optional[Path] = None) -> Dict:
    material_path = Path(material_path)
    file_record = build_file_record(material_path, source_root=source_root)

    result = {
        **file_record,
        "category": SINGLE_FILE,
        "file_kind": "single_file",
        "archive_mtime": None,
        "archive_datetime": None,
        "latest_child_mtime": None,
        "latest_child_datetime": None,
        "child_archive_time_diff_seconds": None,
        "child_source_time_diff_seconds": None,
        "classification_time_basis": None,
        "child_count": 0,
    }

    if not is_archive(material_path):
        return result

    result.update({
        "file_kind": "archive",
        "archive_mtime": file_record["mtime"],
        "archive_datetime": file_record["datetime"],
    })

    extract_dir = Path(work_dir) / material_path.stem
    child_mtime = None
    child_count = 0
    try:
        extracted_files = extract_archive(material_path, extract_dir)
        child_count = len(extracted_files)
        child_mtime = latest_child_mtime_from_files(extracted_files)
    except Exception as exc:
        result["category"] = FAILED_ARCHIVE
        result["error"] = str(exc)
        return result
    finally:
        if extract_dir.exists():
            shutil.rmtree(extract_dir)

    result["child_count"] = child_count
    if child_mtime is None:
        result["category"] = EMPTY_ARCHIVE
        return result

    diff_seconds = child_mtime - file_record["mtime"]
    result.update({
        "category": preliminary_archive_category(file_record["mtime"], child_mtime),
        "latest_child_mtime": child_mtime,
        "latest_child_datetime": format_datetime(child_mtime),
        "child_archive_time_diff_seconds": diff_seconds,
        "classification_time_basis": "archive_mtime_initial",
    })
    return result


def find_zxxk_dirs(source_dir: Path) -> List[Path]:
    source_dir = Path(source_dir)
    if source_dir.name == "学科网" and source_dir.is_dir():
        return [source_dir]
    return sorted(
        (path for path in source_dir.rglob("学科网") if path.is_dir()),
        key=lambda path: str(path).lower(),
    )


def iter_materials(zxxk_dir: Path) -> List[Path]:
    return sorted(
        (item for item in zxxk_dir.iterdir() if item.is_file()),
        key=lambda item: item.name.lower(),
    )


def empty_result(source_dir: Path) -> Dict:
    return {
        "source_dir": str(Path(source_dir).resolve()),
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "total_zxxk_dirs": 0,
        "total_materials": 0,
        "summary": {category: 0 for category in CLASSIFICATION_CATEGORIES},
        "categories": {category: [] for category in CLASSIFICATION_CATEGORIES},
        "other_categories": {},
        "warnings": [],
    }


def add_classification(result: Dict, record: Dict) -> None:
    category = record["category"]
    if category in result["categories"]:
        result["categories"][category].append(record)
        result["summary"][category] += 1
    else:
        result["other_categories"].setdefault(category, []).append(record)
    result["total_materials"] += 1


def classify_zxxk_materials(source_dir: Path, work_dir: Path, upload_time_lookup=None) -> Dict:
    source_dir = Path(source_dir)
    work_dir = Path(work_dir)
    result = empty_result(source_dir)
    zxxk_dirs = find_zxxk_dirs(source_dir)
    result["total_zxxk_dirs"] = len(zxxk_dirs)

    for zxxk_dir in zxxk_dirs:
        word_doc = find_word_doc_for_zxxk_dir(zxxk_dir)
        metadata_rows = extract_zxxk_metadata_rows(word_doc)
        for material_path in iter_materials(zxxk_dir):
            archive_key = hashlib.sha1(str(material_path.resolve()).encode("utf-8")).hexdigest()[:16]
            relative_work_dir = work_dir / archive_key
            record = classify_material(material_path, relative_work_dir, source_root=source_dir)
            enrich_record_with_metadata(record, material_path, metadata_rows)
            try:
                apply_upload_time_lookup(record, upload_time_lookup)
            except Exception as exc:
                result["warnings"].append(f"查询学科网上传日期失败：{exc}")
                upload_time_lookup = None
            reclassify_archive_with_source_time(record)
            if relative_work_dir.exists() and not any(relative_work_dir.iterdir()):
                relative_work_dir.rmdir()
            try:
                record["zxxk_dir"] = str(zxxk_dir.resolve().relative_to(source_dir.resolve()))
            except ValueError:
                record["zxxk_dir"] = str(zxxk_dir.resolve())
            add_classification(result, record)

    return result


def write_json(data: Dict, output_path: Path) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def print_summary(result: Dict, output_path: Path) -> None:
    print("===== 学科网资料分类完成 =====")
    print(f"学科网目录数：{result['total_zxxk_dirs']}")
    print(f"资料总数：{result['total_materials']}")
    print(f"1、单文件：{result['summary'][SINGLE_FILE]}")
    print(f"2、压缩文件，子文件最后时间与压缩包时间在5分钟之内：{result['summary'][ARCHIVE_WITHIN_5_MINUTES]}")
    print(f"3、剩余压缩文件，子文件最后时间比数据库发布时间更晚：{result['summary'][ARCHIVE_CHILD_LATER]}")
    print(f"4、剩余压缩文件，子文件最后时间比数据库发布时间更早：{result['summary'][ARCHIVE_CHILD_EARLIER]}")
    if result["other_categories"]:
        other_total = sum(len(items) for items in result["other_categories"].values())
        print(f"其他/异常：{other_total}")
    print(f"分类结果：{output_path}")


def main() -> None:
    parser = argparse.ArgumentParser(description="学科网资料时间分类程序")
    parser.add_argument("--source-dir", type=str, default=str(BASE_DIR / "ccold"), help="待扫描目录，默认 server/cc")
    parser.add_argument("--work-dir", type=str, default=str(BASE_DIR / "tmp" / "zxxk_material_classify"), help="解压临时目录")
    parser.add_argument("--output", type=str, default=str(BASE_DIR / "jsons" / "zxxk_material_time_categories.json"), help="分类 JSON 输出路径")
    parser.add_argument("--skip-db-time", action="store_true", help="不从 MySQL 查询学科网上传日期")
    args = parser.parse_args()

    source_dir = Path(args.source_dir).expanduser().resolve()
    work_dir = Path(args.work_dir).expanduser().resolve()
    output_path = Path(args.output).expanduser().resolve()

    upload_time_lookup = None if args.skip_db_time else MysqlUploadTimeLookup()
    try:
        result = classify_zxxk_materials(source_dir, work_dir, upload_time_lookup=upload_time_lookup)
    finally:
        if upload_time_lookup:
            upload_time_lookup.close()
    write_json(result, output_path)
    print_summary(result, output_path)


if __name__ == "__main__":
    main()
