#!/usr/bin/env python3
"""maketool-refscan

Two heuristic reports (single command, minimal args):

1) MISSING (used but not installed)
   - If an entry script is provided (Sublime {file}) and it's a .py file,
     AST-scans it and any locally-resolvable sibling modules for imports.
   - Excludes stdlib, local modules/packages, and imports mapped to installed pip dists.
   - Uses a local-roots importability check to reduce false positives.

2) UNUSED files (no filename/path tokens found)
   - Scans the project directory (derived from Sublime {file} or current directory)
   - Shows ONLY unused candidates
   - Groups by extension, separated by a blank line
   - No group headers

Expected input (same style as refscan):
  refscan "C:\\full\\path\\to\\current_file.py"   # Sublime passes {file}
  refscan                                        # scans current directory; missing-imports skipped

Notes:
- Missing-imports requires Python 3.8+ for importlib.metadata.packages_distributions().
"""

from __future__ import annotations

import ast
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, Tuple

try:
    from importlib.metadata import packages_distributions  # py 3.8+
except Exception:
    packages_distributions = None  # type: ignore


# ======================================================
# Refscan config (unused-file token scan)
# ======================================================

DEFAULT_IGNORE_DIRS = {
    ".git", ".hg", ".svn",
    "__pycache__", ".mypy_cache", ".pytest_cache",
    ".venv", "venv", "env",
    "build", "dist", ".tox",
}

DEFAULT_SCAN_EXTS = {
    ".py", ".ui", ".qrc", ".qss",
    ".bat", ".cmd", ".ps1",
    ".txt", ".md", ".rst",
    ".ini", ".cfg", ".toml",
    ".json", ".yaml", ".yml",
    ".xml", ".html", ".htm", ".css",
}

DEFAULT_SKIP_CONTENT_EXTS = {
    ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg",
    ".ico", ".icns",
    ".pdf", ".zip", ".7z", ".rar",
    ".db", ".sqlite", ".dll", ".exe", ".pyd",
}


@dataclass(frozen=True)
class Candidate:
    path: Path
    rel: str
    tokens: Tuple[str, ...]


def iter_files(root: Path, ignore_dirs: Set[str]) -> Iterable[Path]:
    for p in root.rglob("*"):
        if p.is_dir():
            continue
        if any(part in ignore_dirs for part in p.parts):
            continue
        yield p


def safe_read_text(p: Path) -> Optional[str]:
    try:
        return p.read_text(encoding="utf-8", errors="ignore")
    except Exception:
        return None


def norm(s: str) -> str:
    return s.replace("\\", "/").lower()


def build_candidates(root: Path, files: List[Path]) -> List[Candidate]:
    out: List[Candidate] = []
    for p in files:
        rel = str(p.relative_to(root))
        rel_norm = norm(rel)
        base = p.name.lower()
        stem = p.stem.lower()
        tokens = {
            base,
            stem,
            rel_norm,
            rel_norm.replace("/", "\\"),
        }
        out.append(Candidate(p, rel, tuple(t for t in tokens if t)))
    return out


def is_text_source(p: Path) -> bool:
    ext = p.suffix.lower()
    if ext in DEFAULT_SKIP_CONTENT_EXTS:
        return False
    return ext in DEFAULT_SCAN_EXTS


def print_unused_files_grouped(root: Path) -> None:
    files = sorted(iter_files(root, DEFAULT_IGNORE_DIRS))
    candidates = build_candidates(root, files)
    text_sources = [p for p in files if is_text_source(p)]

    references: Dict[str, List[str]] = {c.rel: [] for c in candidates}

    for src in text_sources:
        text = safe_read_text(src)
        if not text:
            continue
        hay = text.lower()
        src_rel = str(src.relative_to(root))

        for c in candidates:
            if src == c.path:
                continue
            if any(tok in hay for tok in c.tokens):
                references[c.rel].append(src_rel)

    unused = [c for c in candidates if not references[c.rel]]

    # group by extension (no headers, blank line between groups)
    groups: Dict[str, List[str]] = defaultdict(list)
    for c in unused:
        ext = c.path.suffix.lower() or ""
        groups[ext].append(c.rel)

    title = "UNUSED files (no filename/path tokens found)"
    print(title)
    print("-" * len(title))

    first = True
    for ext in sorted(groups):
        # if not first: print()  # group spacer
        first = False
        for rel in sorted(groups[ext]):
            print(rel)
    print()


# ======================================================
# Missing-imports report (integrated from report.py)
# ======================================================

def stdlib_names() -> Set[str]:
    names = set(getattr(sys, "stdlib_module_names", set()) or set())
    if names:
        return names

    # Fallback (incomplete) for older Python
    return {
        "os", "sys", "re", "math", "time", "json", "pathlib", "typing", "ast",
        "subprocess", "logging", "datetime", "functools", "itertools", "collections",
        "statistics", "threading", "multiprocessing", "asyncio", "http", "urllib",
        "email", "sqlite3", "csv", "gzip", "zipfile", "hashlib", "base64",
        "ctypes", "inspect", "platform", "shutil", "glob", "fnmatch", "traceback",
        "unittest", "doctest", "xml", "html", "socket", "ssl", "queue",
        "codecs", "shlex", "winreg",
    }


def resolve_local_module(module_name: str, base_dir: Path) -> Optional[Path]:
    rel = Path(*module_name.split("."))
    for candidate in (base_dir / f"{rel}.py", base_dir / rel / "__init__.py"):
        if candidate.exists():
            return candidate
    return None


def imported_top_level_packages(entry_file: Path) -> Set[str]:
    """
    Parse entry_file and recursively parse local imports that resolve within the
    importing file's directory (conservative, no sys.path emulation).
    """
    visited: Set[Path] = set()
    found: Set[str] = set()

    def analyze_file(file_path: Path) -> None:
        if file_path in visited:
            return
        visited.add(file_path)

        try:
            source = file_path.read_text(encoding="utf-8")
            tree = ast.parse(source)
        except Exception:
            return

        base_dir = file_path.parent

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    top = alias.name.split(".")[0]
                    found.add(top)
                    local = resolve_local_module(alias.name, base_dir)
                    if local:
                        analyze_file(local)

            elif isinstance(node, ast.ImportFrom):
                if node.module is None:
                    continue
                top = node.module.split(".")[0]
                found.add(top)
                local = resolve_local_module(node.module, base_dir)
                if local:
                    analyze_file(local)

    analyze_file(entry_file)
    return found


def _index_local_modules_under(root: Path) -> Set[str]:
    found: Set[str] = set()
    if not root.exists():
        return found

    for init_file in root.glob("*/__init__.py"):
        found.add(init_file.parent.name)
    for py in root.glob("*.py"):
        found.add(py.stem)

    return found


def index_local_modules(project_root: Path) -> Set[str]:
    """
    Index local top-level import names from:
      - project_root
      - common source roots under it if present
    (No extra args to keep refscan UX minimal.)
    """
    roots: List[Path] = [project_root]
    for name in ["src", "lib", "app", "python", "package", "packages"]:
        p = project_root / name
        if p.exists() and p.is_dir():
            roots.append(p)

    locals_found: Set[str] = set()
    for r in roots:
        locals_found |= _index_local_modules_under(r)
    return locals_found


def try_importable_as_local(name: str, project_root: Path, extra_roots: List[Path]) -> bool:
    added: List[str] = []
    try:
        for p in [project_root, *extra_roots]:
            sp = str(p)
            if sp and sp not in sys.path:
                sys.path.insert(0, sp)
                added.append(sp)
        __import__(name)
        return True
    except Exception:
        return False
    finally:
        for sp in added:
            try:
                sys.path.remove(sp)
            except ValueError:
                pass


def compute_missing_used_imports(
    used_import_names: Set[str],
    std: Set[str],
    local_index: Set[str],
    import_to_dists: Dict[str, List[str]],
    project_root: Path,
) -> Set[str]:
    """
    Missing = not stdlib, not local, not mapped to installed dists, and not importable
    when project roots are on sys.path.
    """
    missing: Set[str] = set()
    extra_roots: List[Path] = []  # keep refscan input simple; no extra flags

    for name in used_import_names:
        if not name or name in std:
            continue
        if name in local_index:
            continue
        if import_to_dists.get(name):
            continue
        if try_importable_as_local(name, project_root, extra_roots):
            continue
        try:
            __import__(name)
            continue
        except Exception:
            missing.add(name)

    return missing


def print_missing_imports(entry: Path) -> None:

    title = "MISSING (used but not installed):"
    print(title)
    print("-" * len(title))

    if packages_distributions is None:
        print("(skipped: Python 3.8+ required for packages_distributions())")
        print()
        return

    if not entry.exists() or entry.suffix.lower() != ".py":
        print("(skipped: entry is not a .py file)")
        print()
        return

    project_root = entry.parent  # matches your earlier behavior (reliable + simple)
    std = stdlib_names()
    local_index = index_local_modules(project_root)
    used_import_names = imported_top_level_packages(entry)
    import_to_dists: Dict[str, List[str]] = packages_distributions() or {}

    missing = compute_missing_used_imports(
        used_import_names=used_import_names,
        std=std,
        local_index=local_index,
        import_to_dists=import_to_dists,
        project_root=project_root,
    )

    if missing:
        for m in sorted(missing):
            print(f"{m}")
    else:
        print("(none)")
    print()


# ======================================================
# Main (refscan UX)
# ======================================================

def main() -> int:

    # refscan input: optional positional path (Sublime {file}); otherwise CWD
    entry: Optional[Path] = None
    root = Path.cwd().resolve()

    if len(sys.argv) >= 2:
        p = Path(sys.argv[1]).expanduser()
        try:
            p = p.resolve()
        except Exception:
            pass

        # If Sublime passes a file, scan its parent dir; also use it as entry if .py
        if p.suffix:
            root = p.parent
            if p.suffix.lower() == ".py":
                entry = p
        else:
            # If user passes a directory, use it as root; no entry inferred
            if p.exists() and p.is_dir():
                root = p



    print_unused_files_grouped(root)

    if entry is not None:
        print_missing_imports(entry)
    else:
        print("MISSING (used but not installed):")
        print("  (skipped: no entry .py provided)")
        print()

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
