#!/usr/bin/env python3
"""
our-gdrive-organizer — organize a Google Drive folder under OurDigital conventions.

Scans the target directory (default: 2nd-level Drive subfolder), refreshes the
README.md index, proposes renames + moves to bring files into convention, and
optionally applies them.

Usage:
    organizer.py [TARGET]                          # dry-run report
    organizer.py [TARGET] --apply                  # apply all proposed changes
    organizer.py [TARGET] --scope index            # only refresh root README
    organizer.py [TARGET] --scope subreadmes       # refresh per-subfolder READMEs
    organizer.py [TARGET] --scope rename           # only propose/apply renames
    organizer.py [TARGET] --scope move             # only propose/apply moves
    organizer.py [TARGET] --scope full             # everything (default)
    organizer.py [TARGET] --json                   # machine-readable output

If TARGET is omitted, uses the current working directory.

The script is stdlib-only (Python 3.9+). Safe by default — never writes unless
--apply is passed (or running --scope index, which always writes the README).
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import sys
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable

# ---------------------------------------------------------------------------
# Configuration: naming convention enforcement.
# Edit shared/conventions.md for the human-readable spec; this dict is the
# source of truth for the script.
# ---------------------------------------------------------------------------

RENAME_RULES: list[tuple[re.Pattern[str], str, str]] = [
    # (pattern, replacement, reason)
    # SEO context only — D.intelligence is the parent company; OurDigital is its
    # SEO-specialty child brand. Only apply these renames when operating inside
    # an SEO folder (02_SEO in Action, 04_SEO, etc.). Outside SEO context, keep
    # D.intelligence names intact (they belong to the parent company). The
    # script doesn't enforce this caveat — it relies on the caller pointing at
    # the right target. See shared/patterns/gotchas.md "D.intelligence vs
    # OurDigital" for the full rule.
    (re.compile(r"D\.intelligence Lab-", re.I), "OurDigital-",
     "parent-co SEO asset → OurDigital-"),
    (re.compile(r"D\.intelligence", re.I), "OurDigital",
     "parent-co SEO asset → OurDigital"),
    # Variant without the dot (typo / informal spelling).
    (re.compile(r"\bD intelligence\b", re.I), "OurDigital",
     "parent-co SEO asset (no-dot variant) → OurDigital"),
]

# Files matching these patterns at the root of a top-level subfolder are
# proposed to be moved into a sibling subfolder.
MOVE_RULES: list[tuple[re.Pattern[str], str, str]] = [
    (re.compile(r"^Screenshot \d{4}-\d{2}-\d{2}.*\.png$"), "screenshots",
     "screenshot file → screenshots/ subfolder"),
    (re.compile(r"^.*\.(crdownload|tmp|partial)$"), "_unsorted",
     "incomplete / temp file → _unsorted/"),
]

# Skip these subfolders for invasive changes (renames + moves).
# Indexing still includes them, but their internal files aren't reorganized.
SENSITIVE_SUBFOLDER_PATTERNS = [
    re.compile(r"^04_Case Studies$"),
    re.compile(r"^99_Project Archive$"),
    re.compile(r".*Archive$", re.I),
    re.compile(r"^진단.*$"),
]

EXCLUDE_NAMES = {".DS_Store", "Icon\r"}

BEGIN_MARK = "<!-- AUTO-STRUCTURE:BEGIN -->"
END_MARK = "<!-- AUTO-STRUCTURE:END -->"

# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class FolderScan:
    path: Path
    files: list[str] = field(default_factory=list)
    subfolders: dict[str, "FolderScan"] = field(default_factory=dict)


@dataclass
class Proposal:
    kind: str            # "rename" | "move"
    src: Path
    dst: Path
    reason: str

    def as_dict(self) -> dict:
        return {"kind": self.kind, "src": str(self.src),
                "dst": str(self.dst), "reason": self.reason}


# ---------------------------------------------------------------------------
# Scanning
# ---------------------------------------------------------------------------


def is_visible(name: str) -> bool:
    return not name.startswith(".") and name not in EXCLUDE_NAMES


def list_visible_files(path: Path) -> list[str]:
    try:
        return sorted(e.name for e in os.scandir(path)
                      if e.is_file(follow_symlinks=False) and is_visible(e.name))
    except OSError:
        return []


def list_visible_dirs(path: Path) -> list[str]:
    try:
        return sorted(e.name for e in os.scandir(path)
                      if e.is_dir(follow_symlinks=False) and is_visible(e.name))
    except OSError:
        return []


def scan(path: Path, depth: int = 3) -> FolderScan:
    """Walk depth levels deep. depth=3 → root + 2 levels of subfolders."""
    s = FolderScan(path=path, files=list_visible_files(path))
    if depth > 1:
        for d in list_visible_dirs(path):
            s.subfolders[d] = scan(path / d, depth - 1)
    else:
        for d in list_visible_dirs(path):
            s.subfolders[d] = FolderScan(path=path / d)
    return s


def is_sensitive(name: str) -> bool:
    return any(p.search(name) for p in SENSITIVE_SUBFOLDER_PATTERNS)


# ---------------------------------------------------------------------------
# Index generation (root README.md and per-subfolder READMEs)
# ---------------------------------------------------------------------------


def render_structure_block(scan_root: FolderScan) -> str:
    out: list[str] = []
    out.append("## Structure (3 levels)")
    out.append("")
    ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z")
    out.append(f"_Auto-regenerated {ts}. Edits inside the AUTO-STRUCTURE markers will be overwritten._")
    out.append("")
    for d1_name in sorted(scan_root.subfolders):
        d1 = scan_root.subfolders[d1_name]
        nf1 = len(d1.files)
        nd1 = len(d1.subfolders)
        out.append(f"- **{d1_name}/** ({nf1} files, {nd1} subfolders)")
        for d2_name in sorted(d1.subfolders):
            d2 = d1.subfolders[d2_name]
            nf2 = len(d2.files)
            nd2 = len(d2.subfolders)
            if nd2 > 0:
                out.append(f"  - {d2_name}/ ({nf2}f, {nd2}d)")
                for d3_name in sorted(d2.subfolders):
                    d3 = d2.subfolders[d3_name]
                    nf3 = len(d3.files)
                    out.append(f"    - {d3_name}/ ({nf3}f)")
            else:
                out.append(f"  - {d2_name}/ ({nf2}f)")
        if nf1 > 0:
            preview = ", ".join(f"`{n}`" for n in d1.files[:5])
            out.append(f"  - _files_: {preview}")
        out.append("")
    return "\n".join(out).rstrip() + "\n"


def render_subfolder_block(scan_node: FolderScan) -> str:
    """Smaller block for per-subfolder README.md (2 levels deep)."""
    out: list[str] = []
    out.append("## Contents")
    out.append("")
    ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z")
    out.append(f"_Auto-regenerated {ts}._")
    out.append("")
    if scan_node.files:
        out.append(f"**Files at root** ({len(scan_node.files)}):")
        for f in scan_node.files[:20]:
            out.append(f"- `{f}`")
        if len(scan_node.files) > 20:
            out.append(f"- … and {len(scan_node.files) - 20} more")
        out.append("")
    if scan_node.subfolders:
        out.append(f"**Subfolders** ({len(scan_node.subfolders)}):")
        for d_name in sorted(scan_node.subfolders):
            d = scan_node.subfolders[d_name]
            nf = len(d.files)
            nd = len(d.subfolders)
            extras = f", {nd} dirs" if nd else ""
            out.append(f"- `{d_name}/` ({nf} files{extras})")
        out.append("")
    return "\n".join(out).rstrip() + "\n"


def splice_readme(readme_path: Path, new_block: str, *, header_default: str | None = None) -> tuple[bool, str]:
    """Insert/replace the AUTO-STRUCTURE block inside readme_path.

    Returns (changed, summary). Creates the file with header_default if missing.
    """
    if not readme_path.exists():
        if header_default is None:
            return (False, f"skip: {readme_path} does not exist")
        readme_path.write_text(
            f"{header_default}\n\n{BEGIN_MARK}\n{new_block}{END_MARK}\n",
            encoding="utf-8",
        )
        return (True, f"created: {readme_path}")

    text = readme_path.read_text(encoding="utf-8")

    # Update top-level Snapshot counts if they exist
    parent = readme_path.parent
    total_files = len(list_visible_files(parent))
    total_dirs = len(list_visible_dirs(parent))
    text = re.sub(r"^- \*\*Top-level files\*\*:.*$",
                  f"- **Top-level files**: {total_files}", text, count=1, flags=re.M)
    text = re.sub(r"^- \*\*Top-level subfolders\*\*:.*$",
                  f"- **Top-level subfolders**: {total_dirs}", text, count=1, flags=re.M)

    if BEGIN_MARK in text and END_MARK in text:
        pattern = re.compile(re.escape(BEGIN_MARK) + r".*?" + re.escape(END_MARK), re.S)
        new_text = pattern.sub(BEGIN_MARK + "\n" + new_block + END_MARK, text)
    else:
        # Append marker block before the trailing footer (if any) or at EOF
        new_text = text.rstrip() + "\n\n" + BEGIN_MARK + "\n" + new_block + END_MARK + "\n"

    if structurally_equal(text, new_text):
        return (False, f"no structural changes: {readme_path}")
    readme_path.write_text(new_text, encoding="utf-8")
    return (True, f"updated: {readme_path}")


def structurally_equal(a: str, b: str) -> bool:
    """Strip the auto-regenerated timestamp lines before comparing."""
    strip = re.compile(r"^_Auto-regenerated .*$", re.M)
    return strip.sub("", a) == strip.sub("", b)


# ---------------------------------------------------------------------------
# Rename + move proposals
# ---------------------------------------------------------------------------


def propose_renames(scan_root: FolderScan) -> list[Proposal]:
    proposals: list[Proposal] = []
    def visit(node: FolderScan):
        for f in node.files:
            new = f
            reasons: list[str] = []
            for pat, repl, why in RENAME_RULES:
                next_new = pat.sub(repl, new)
                if next_new != new:
                    reasons.append(why)
                    new = next_new
            if new != f:
                proposals.append(Proposal("rename", node.path / f,
                                          node.path / new, "; ".join(reasons)))
        for sub_name, sub in node.subfolders.items():
            if is_sensitive(sub_name):
                continue
            visit(sub)
    visit(scan_root)
    return proposals


def propose_folder_renames(scan_root: FolderScan) -> list[Proposal]:
    """Propose folder renames matching RENAME_RULES.

    Guardrails:
    - Top-level subfolders (depth 1, e.g. "00_Brand Management/") are NEVER
      renamed automatically — they're the user's manually curated practice
      areas. Use a one-off `mv` if you need to rename one.
    - Sensitive folders are skipped entirely (not renamed, not recursed into).
    """
    proposals: list[Proposal] = []

    def visit(node: FolderScan, parent_depth: int):
        sub_depth = parent_depth + 1
        for sub_name, sub in node.subfolders.items():
            if is_sensitive(sub_name):
                continue
            if sub_depth >= 2:  # depth-2+ folders are eligible for rename
                new_name = sub_name
                reasons: list[str] = []
                for pat, repl, why in RENAME_RULES:
                    nxt = pat.sub(repl, new_name)
                    if nxt != new_name:
                        reasons.append(why)
                        new_name = nxt
                if new_name != sub_name:
                    proposals.append(Proposal(
                        "rename-folder", sub.path,
                        sub.path.parent / new_name,
                        "; ".join(reasons),
                    ))
            visit(sub, sub_depth)

    visit(scan_root, parent_depth=0)
    return proposals


def propose_moves(scan_root: FolderScan) -> list[Proposal]:
    proposals: list[Proposal] = []
    # Only operate on top-level subfolders of the target — that's where the
    # "cluttered files at root" pattern most commonly appears.
    for sub_name, sub in scan_root.subfolders.items():
        if is_sensitive(sub_name):
            continue
        for f in sub.files:
            for pat, dest_subdir, why in MOVE_RULES:
                if pat.search(f):
                    proposals.append(Proposal(
                        "move",
                        sub.path / f,
                        sub.path / dest_subdir / f,
                        why,
                    ))
                    break
    return proposals


def apply_proposals(props: Iterable[Proposal]) -> list[tuple[Proposal, str]]:
    results: list[tuple[Proposal, str]] = []
    for p in props:
        try:
            if p.kind == "move":
                p.dst.parent.mkdir(parents=True, exist_ok=True)
            if p.dst.exists():
                results.append((p, "skip: destination exists"))
                continue
            shutil.move(str(p.src), str(p.dst))
            results.append((p, "ok"))
        except OSError as e:
            results.append((p, f"error: {e}"))
    return results


# ---------------------------------------------------------------------------
# Per-subfolder README.md ensuring + refresh
# ---------------------------------------------------------------------------


def refresh_subfolder_readmes(scan_root: FolderScan, *, write: bool) -> list[str]:
    """For each top-level subfolder, ensure README.md exists with AUTO block."""
    log: list[str] = []
    for sub_name in sorted(scan_root.subfolders):
        if is_sensitive(sub_name):
            log.append(f"skip (sensitive): {sub_name}/")
            continue
        sub = scan_root.subfolders[sub_name]
        readme = sub.path / "README.md"
        block = render_subfolder_block(sub)
        header = f"# {sub_name}\n\n_OurDigital — auto-indexed subfolder README._"
        if not write:
            log.append(f"would refresh: {readme}")
            continue
        changed, summary = splice_readme(readme, block, header_default=header)
        log.append(summary)
    return log


# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------


def text_report(target: Path, renames: list[Proposal], folder_renames: list[Proposal],
                moves: list[Proposal], index_summary: str, sub_log: list[str]) -> str:
    lines = []
    lines.append(f"# our-gdrive-organizer report")
    lines.append(f"target: {target}")
    lines.append(f"scan time: {datetime.now().strftime('%F %T')}")
    lines.append("")
    lines.append("## Index")
    lines.append(index_summary or "(skipped)")
    lines.append("")
    lines.append("## Subfolder READMEs")
    if sub_log:
        for line in sub_log:
            lines.append(f"- {line}")
    else:
        lines.append("(skipped)")
    lines.append("")
    lines.append(f"## File rename proposals ({len(renames)})")
    if renames:
        for p in renames:
            lines.append(f"- {p.src.relative_to(target)}")
            lines.append(f"  → {p.dst.relative_to(target)}")
            lines.append(f"  ({p.reason})")
    else:
        lines.append("(none)")
    lines.append("")
    lines.append(f"## Folder rename proposals ({len(folder_renames)})")
    if folder_renames:
        for p in folder_renames:
            lines.append(f"- {p.src.relative_to(target)}/")
            lines.append(f"  → {p.dst.relative_to(target)}/")
            lines.append(f"  ({p.reason})")
    else:
        lines.append("(none)")
    lines.append("")
    lines.append(f"## Move proposals ({len(moves)})")
    if moves:
        for p in moves:
            lines.append(f"- {p.src.relative_to(target)}")
            lines.append(f"  → {p.dst.relative_to(target)}")
            lines.append(f"  ({p.reason})")
    else:
        lines.append("(none)")
    return "\n".join(lines) + "\n"


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="OurDigital Google Drive folder organizer",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("target", nargs="?", default=os.getcwd(),
                        help="target directory (default: cwd)")
    parser.add_argument("--apply", action="store_true",
                        help="actually perform renames and moves (default: dry-run)")
    parser.add_argument("--scope", choices=["full", "index", "subreadmes",
                                            "rename", "move"], default="full",
                        help="restrict the operation (default: full)")
    parser.add_argument("--json", action="store_true",
                        help="emit machine-readable JSON instead of a text report")

    args = parser.parse_args(argv)
    target = Path(args.target).expanduser().resolve()
    if not target.is_dir():
        print(f"target is not a directory: {target}", file=sys.stderr)
        return 1

    scan_root = scan(target, depth=3)

    index_summary = ""
    sub_log: list[str] = []
    renames: list[Proposal] = []
    folder_renames: list[Proposal] = []
    moves: list[Proposal] = []

    if args.scope in ("full", "index"):
        readme = target / "README.md"
        block = render_structure_block(scan_root)
        header = f"# {target.name}\n\n_OurDigital — auto-indexed root README._\n\n## Snapshot\n\n- **Top-level files**: {len(scan_root.files)}\n- **Top-level subfolders**: {len(scan_root.subfolders)}"
        changed, index_summary = splice_readme(readme, block, header_default=header)

    if args.scope in ("full", "subreadmes"):
        sub_log = refresh_subfolder_readmes(scan_root, write=True)

    # Order matters when --apply: file renames first, then folder renames.
    # If folders are renamed first, file paths inside them become invalid.
    if args.scope in ("full", "rename"):
        renames = propose_renames(scan_root)
        if args.apply and renames:
            results = apply_proposals(renames)
            for p, status in results:
                p.reason = f"{p.reason} [{status}]"

        folder_renames = propose_folder_renames(scan_root)
        if args.apply and folder_renames:
            # Apply deepest folders first — renaming a parent invalidates the
            # path of any nested rename. shutil.move on the deepest target
            # leaves the parent's name intact and avoids stale paths.
            ordered = sorted(folder_renames, key=lambda p: len(p.src.parts), reverse=True)
            results = apply_proposals(ordered)
            for p, status in results:
                p.reason = f"{p.reason} [{status}]"

    if args.scope in ("full", "move"):
        moves = propose_moves(scan_root)
        if args.apply and moves:
            results = apply_proposals(moves)
            for p, status in results:
                p.reason = f"{p.reason} [{status}]"

    if args.json:
        out = {
            "target": str(target),
            "scope": args.scope,
            "applied": args.apply,
            "index": index_summary,
            "subfolder_readmes": sub_log,
            "renames": [p.as_dict() for p in renames],
            "folder_renames": [p.as_dict() for p in folder_renames],
            "moves": [p.as_dict() for p in moves],
        }
        print(json.dumps(out, ensure_ascii=False, indent=2))
    else:
        print(text_report(target, renames, folder_renames, moves, index_summary, sub_log))

    return 0


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