"""工作区内代码行与文档字数统计（纯逻辑，路径由路由层解析为绝对路径）。"""

from __future__ import annotations

import os
import re
from collections import defaultdict
from html.parser import HTMLParser
from pathlib import Path
from typing import Any

DEFAULT_CODE_EXTENSIONS = frozenset(
    {
        ".py",
        ".pyi",
        ".js",
        ".mjs",
        ".cjs",
        ".ts",
        ".tsx",
        ".jsx",
        ".vue",
        ".svelte",
        ".go",
        ".rs",
        ".java",
        ".kt",
        ".swift",
        ".rb",
        ".php",
        ".cs",
        ".c",
        ".h",
        ".cpp",
        ".hpp",
        ".cc",
        ".sql",
        ".sh",
        ".bash",
        ".zsh",
        ".ps1",
        ".bat",
        ".cmd",
        ".r",
        ".scala",
        ".clj",
        ".ex",
        ".exs",
        ".dart",
        ".lua",
    }
)

DEFAULT_DOC_EXTENSIONS = frozenset({".md", ".txt", ".rst", ".adoc", ".tex"})

DEFAULT_EXCLUDE_DIR_NAMES = frozenset(
    {
        ".git",
        ".svn",
        ".hg",
        "node_modules",
        "__pycache__",
        ".venv",
        "venv",
        ".nox",
        ".tox",
        "dist",
        "build",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
        "target",
        ".gradle",
        "eggs",
        ".eggs",
    }
)


class _StripHTMLParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self._chunks: list[str] = []

    def handle_data(self, data: str) -> None:
        self._chunks.append(data)

    def text(self) -> str:
        return "".join(self._chunks)


def _strip_html_to_text(html_doc: str) -> str:
    p = _StripHTMLParser()
    p.feed(html_doc)
    p.close()
    return " ".join(p.text().split())


def _parse_extensions(raw: Any, fallback: frozenset[str]) -> frozenset[str]:
    if raw is None:
        return fallback
    if isinstance(raw, str) and raw.strip():
        parts = [x.strip().lower() for x in raw.replace(",", " ").split()]
        out = []
        for p in parts:
            if not p.startswith("."):
                p = "." + p
            out.append(p)
        return frozenset(out)
    if isinstance(raw, list):
        out: list[str] = []
        for x in raw:
            p = str(x).strip().lower()
            if not p.startswith("."):
                p = "." + p
            out.append(p)
        return frozenset(out) if out else fallback
    return fallback


def _parse_exclude_dirs(raw: Any) -> frozenset[str]:
    base = set(DEFAULT_EXCLUDE_DIR_NAMES)
    if raw is None:
        return frozenset(base)
    extra: list[str] = []
    if isinstance(raw, list):
        extra = [str(x).strip() for x in raw if str(x).strip()]
    elif isinstance(raw, str) and raw.strip():
        extra = [x.strip() for x in raw.replace(",", " ").split() if x.strip()]
    for e in extra:
        base.add(e)
    return frozenset(base)


def op_code_lines(payload: dict[str, Any]) -> dict[str, Any]:
    root_s = str(payload.get("root") or "").strip()
    if not root_s:
        return {"ok": False, "error": "root is required (absolute or workspace-relative)"}
    root = Path(root_s).resolve()
    if not root.is_dir():
        return {"ok": False, "error": f"root is not a directory: {root}"}

    exts = _parse_extensions(payload.get("extensions"), DEFAULT_CODE_EXTENSIONS)
    exclude_dirs = _parse_exclude_dirs(payload.get("exclude_dir_names"))
    try:
        max_files = int(payload.get("max_files", 5000))
    except (TypeError, ValueError):
        max_files = 5000
    max_files = max(10, min(50_000, max_files))
    try:
        max_file_bytes = int(payload.get("max_file_bytes", 2_000_000))
    except (TypeError, ValueError):
        max_file_bytes = 2_000_000
    max_file_bytes = max(1024, min(20_000_000, max_file_bytes))

    by_ext: dict[str, dict[str, int]] = defaultdict(
        lambda: {"files": 0, "lines": 0, "non_blank_lines": 0}
    )
    files_done = 0
    files_skipped_large = 0
    truncated = False

    for dirpath, dirnames, filenames in os.walk(root, topdown=True):
        dirnames[:] = [
            d
            for d in dirnames
            if d not in exclude_dirs and not d.endswith(".egg-info")
        ]
        for fn in filenames:
            if files_done >= max_files:
                truncated = True
                break
            p = Path(dirpath) / fn
            suf = p.suffix.lower()
            if suf not in exts:
                continue
            try:
                st = p.stat()
            except OSError:
                continue
            if st.st_size > max_file_bytes:
                files_skipped_large += 1
                continue
            try:
                raw = p.read_bytes()
            except OSError:
                continue
            try:
                text = raw.decode("utf-8", errors="replace")
            except Exception:
                continue
            lines = text.splitlines()
            n_lines = len(lines)
            non_blank = sum(1 for ln in lines if ln.strip())
            files_done += 1
            bucket = by_ext[suf]
            by_ext[suf] = {
                "files": bucket["files"] + 1,
                "lines": bucket["lines"] + n_lines,
                "non_blank_lines": bucket["non_blank_lines"] + non_blank,
            }
        if truncated:
            break

    total_files = sum(b["files"] for b in by_ext.values())
    total_lines = sum(b["lines"] for b in by_ext.values())
    total_non_blank = sum(b["non_blank_lines"] for b in by_ext.values())
    by_ext_out = {k: dict(v) for k, v in sorted(by_ext.items())}

    return {
        "ok": True,
        "root": str(root),
        "extensions_scanned": sorted(exts),
        "total_files": total_files,
        "total_lines": total_lines,
        "total_non_blank_lines": total_non_blank,
        "by_extension": by_ext_out,
        "files_enumerated_cap": max_files,
        "truncated_file_list": truncated,
        "files_skipped_too_large": files_skipped_large,
        "hint": "Use exclude_dir_names or narrower extensions if counts omit expected trees; "
        "large files skipped are reported in files_skipped_too_large.",
    }


def op_document_words(payload: dict[str, Any]) -> dict[str, Any]:
    root_s = str(payload.get("root") or "").strip()
    paths_raw = payload.get("paths")
    exts = _parse_extensions(payload.get("extensions"), DEFAULT_DOC_EXTENSIONS)
    exclude_dirs = _parse_exclude_dirs(payload.get("exclude_dir_names"))
    try:
        max_files = int(payload.get("max_files", 2000))
    except (TypeError, ValueError):
        max_files = 2000
    max_files = max(1, min(20_000, max_files))
    try:
        max_file_bytes = int(payload.get("max_file_bytes", 5_000_000))
    except (TypeError, ValueError):
        max_file_bytes = 5_000_000
    max_file_bytes = max(1024, min(40_000_000, max_file_bytes))
    include_html = bool(payload.get("include_html", False))
    if include_html:
        exts = frozenset(exts) | {".html", ".htm"}

    per_file: list[dict[str, Any]] = []
    total_words = 0
    total_chars = 0
    total_chars_nospace = 0
    total_lines = 0
    files_used = 0
    files_skipped_large = 0

    def _accumulate(text: str, rel: str) -> None:
        nonlocal total_words, total_chars, total_chars_nospace, total_lines, files_used
        lines = text.splitlines()
        total_lines += len(lines)
        # 字数：中日韩等连续字符按字计 + 拉丁空白分词
        words = len([w for w in re.split(r"[\s\u3000]+", text) if w])
        chars = len(text)
        nospace = len(re.sub(r"\s+", "", text))
        total_words += words
        total_chars += chars
        total_chars_nospace += nospace
        files_used += 1
        per_file.append(
            {
                "path": rel,
                "lines": len(lines),
                "words_whitespace_units": words,
                "chars": chars,
                "chars_no_whitespace": nospace,
            }
        )

    if paths_raw is not None and isinstance(paths_raw, list) and paths_raw:
        for item in paths_raw[:max_files]:
            ps = str(item).strip()
            if not ps:
                continue
            p = Path(ps).resolve()
            if not p.is_file():
                continue
            try:
                st = p.stat()
            except OSError:
                continue
            if st.st_size > max_file_bytes:
                files_skipped_large += 1
                continue
            suf = p.suffix.lower()
            if suf not in exts:
                continue
            try:
                raw = p.read_bytes()
            except OSError:
                continue
            text = raw.decode("utf-8", errors="replace")
            if suf in (".html", ".htm"):
                text = _strip_html_to_text(text)
            _accumulate(text, str(p))
        return {
            "ok": True,
            "mode": "paths",
            "total_files": files_used,
            "total_lines": total_lines,
            "total_words_whitespace_units": total_words,
            "total_chars": total_chars,
            "total_chars_no_whitespace": total_chars_nospace,
            "per_file": per_file[:200],
            "per_file_omitted": max(0, len(per_file) - 200),
            "files_skipped_too_large": files_skipped_large,
            "note_zh": "中文场景下 total_chars / total_chars_no_whitespace 更接近「字数」；"
            "words_whitespace_units 偏英文化空白分词。",
        }

    if not root_s:
        return {
            "ok": False,
            "error": "pass root (directory) or paths (file list)",
        }
    root = Path(root_s).resolve()
    if not root.is_dir():
        return {"ok": False, "error": f"root is not a directory: {root}"}

    try:
        glob_pat = str(payload.get("glob") or "**/*")
    except Exception:
        glob_pat = "**/*"

    truncated = False
    for p in root.glob(glob_pat):
        if files_used >= max_files:
            truncated = True
            break
        if not p.is_file():
            continue
        try:
            rel_parts = p.resolve().relative_to(root.resolve()).parts
        except ValueError:
            continue
        if any(part in exclude_dirs for part in rel_parts):
            continue
        if any(part.endswith(".egg-info") for part in rel_parts):
            continue
        suf = p.suffix.lower()
        if suf not in exts:
            continue
        try:
            st = p.stat()
        except OSError:
            continue
        if st.st_size > max_file_bytes:
            files_skipped_large += 1
            continue
        try:
            raw = p.read_bytes()
        except OSError:
            continue
        text = raw.decode("utf-8", errors="replace")
        if suf in (".html", ".htm"):
            text = _strip_html_to_text(text)
        _accumulate(text, str(p.relative_to(root)).replace("\\", "/"))

    if files_used == 0 and not truncated:
        return {
            "ok": True,
            "root": str(root),
            "glob": glob_pat,
            "total_files": 0,
            "total_lines": 0,
            "total_words_whitespace_units": 0,
            "total_chars": 0,
            "total_chars_no_whitespace": 0,
            "per_file": [],
            "hint": "No matching files; check glob, extensions, exclude_dir_names.",
        }

    return {
        "ok": True,
        "mode": "glob",
        "root": str(root),
        "glob": glob_pat,
        "total_files": files_used,
        "total_lines": total_lines,
        "total_words_whitespace_units": total_words,
        "total_chars": total_chars,
        "total_chars_no_whitespace": total_chars_nospace,
        "per_file": per_file[:200],
        "per_file_omitted": max(0, len(per_file) - 200),
        "truncated_file_list": truncated,
        "files_skipped_too_large": files_skipped_large,
        "note_zh": "中文场景下 total_chars / total_chars_no_whitespace 更接近「字数」；"
        "words_whitespace_units 偏英文化空白分词。",
    }


def run_operation(operation: str, payload: dict[str, Any]) -> dict[str, Any]:
    op = (operation or "").strip().lower().replace("-", "_")
    if op in ("code_lines", "code_line_stats", "lines"):
        return op_code_lines(payload)
    if op in ("document_words", "doc_words", "word_count"):
        return op_document_words(payload)
    return {
        "ok": False,
        "error": f"unknown operation: {operation!r}",
        "hint": "use code_lines | document_words",
    }
