#!/usr/bin/env python3
"""
Create a clean Fullbleed repo snapshot by copying only launch-required files.

Examples:
  python repo_fix.py --dest C:\\dev\\workbench\\fullbleed-official --profile launch --clean
  python repo_fix.py --dest ../fullbleed-runtime --profile runtime --dry-run --verbose
"""

from __future__ import annotations

import argparse
import fnmatch
import json
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple


@dataclass(frozen=True)
class Profile:
    required_files: Tuple[str, ...]
    required_dirs: Tuple[str, ...]
    optional_files: Tuple[str, ...] = ()
    optional_dirs: Tuple[str, ...] = ()


PROFILES: Dict[str, Profile] = {
    "runtime": Profile(
        required_files=(
            "Cargo.toml",
            "Cargo.lock",
            "pyproject.toml",
            "README.md",
            "LICENSE",
            "COPYRIGHT",
            "THIRD_PARTY_LICENSES.md",
            ".gitignore",
            "repo_fix.py",
        ),
        required_dirs=(
            "src",
            "python/fullbleed",
            "python/fullbleed_assets",
            "python/fullbleed_cli",
        ),
    ),
    "launch": Profile(
        required_files=(
            "Cargo.toml",
            "Cargo.lock",
            "pyproject.toml",
            "README.md",
            "AI.md",
            "LICENSE",
            "LICENSING.md",
            "COPYRIGHT",
            "THIRD_PARTY_LICENSES.md",
            ".gitignore",
            "repo_fix.py",
            "CLI_EPOCH.md",
            "cli_schema.md",
            "cli_launch_reflight_plan.md",
            "implementation_friction.md",
            "FONT_LICENSE_AUDIT.md",
            "FONT_LICENSE_AUDIT.json",
            "ASSET_COMPLIANCE_PREFLIGHT_RESEARCH.md",
        ),
        required_dirs=(
            "src",
            "python/fullbleed",
            "python/fullbleed_assets",
            "python/fullbleed_cli",
            "templates",
            "tools",
            "examples/living_docs_atlas",
        ),
        optional_dirs=(
            "examples/roofing_invoice",
            "examples/iconography_test",
        ),
    ),
}


DEFAULT_EXCLUDE_PATTERNS: Tuple[str, ...] = (
    ".git/**",
    ".venv/**",
    ".pytest_cache/**",
    "__pycache__/**",
    "**/__pycache__/**",
    "**/.pytest_cache/**",
    "target/**",
    "target_py/**",
    "output/**",
    "cli_test2/**",
    "test_auto_assets/**",
    "test_cli_pdf/**",
    "test_minimal/**",
    "**/*.pyc",
    "**/*.pyo",
    "**/*.pyd",
    "**/*.so",
    "**/*.dll",
    "**/*.dylib",
    "**/*.log",
    "**/*.tmp",
    ".tmp*",
    "**/.tmp*",
    "*.pdf",
    "*.coverage",
    ".coverage",
    "build_log*.txt",
    # Enforce single-source production CLI tree.
    "fullbleed_cli/**",
    # Exclude generated example artifacts; keep source templates/inputs only.
    "examples/**/out/**",
    "examples/**/output/**",
    "examples/**/build/**",
    "examples/**/.tmp*/**",
    # Exclude generated example artifacts; keep only source inputs/templates/scripts.
    "examples/living_docs_atlas/build/**",
    "examples/living_docs_atlas/.tmp*/**",
    "examples/living_docs_atlas/vendor/*.ttf",
    "examples/living_docs_atlas/vendor/fonts/*.ttf",
    "examples/living_docs_atlas/vendor/LICENSE.txt",
    "examples/living_docs_atlas/vendor/bootstrap.min.css",
    "examples/living_docs_atlas/vendor/css/bootstrap.min.css",
)


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Copy a clean Fullbleed launch/runtime snapshot to a new directory."
    )
    parser.add_argument(
        "--dest",
        required=True,
        help="Destination directory for the copied snapshot.",
    )
    parser.add_argument(
        "--profile",
        choices=sorted(PROFILES.keys()),
        default="launch",
        help="Copy policy profile.",
    )
    parser.add_argument(
        "--clean",
        action="store_true",
        help="Delete destination directory before copy.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be copied, but do not copy files.",
    )
    parser.add_argument(
        "--allow-missing",
        action="store_true",
        help="Do not fail when a required path is missing.",
    )
    parser.add_argument(
        "--exclude",
        action="append",
        default=[],
        help="Additional exclude glob pattern (can be repeated).",
    )
    parser.add_argument(
        "--manifest",
        default=None,
        help="Path to write JSON manifest. Defaults to <dest>/repo_fix_manifest.json.",
    )
    parser.add_argument(
        "--manifest-full",
        action="store_true",
        help="Include full copied file list in manifest JSON.",
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="Print copied files.",
    )
    return parser.parse_args()


def _rel_posix(path: Path, root: Path) -> str:
    return path.relative_to(root).as_posix()


def _matches_pattern(rel_path: str, pattern: str) -> bool:
    if fnmatch.fnmatchcase(rel_path, pattern):
        return True
    # Also treat "dir/**" as matching the dir itself.
    if pattern.endswith("/**"):
        base = pattern[:-3].rstrip("/")
        return rel_path == base
    return False


def _first_matching_pattern(rel_path: str, patterns: Sequence[str]) -> Optional[str]:
    for pattern in patterns:
        if _matches_pattern(rel_path, pattern):
            return pattern
    return None


def _collect_file_candidates(
    root: Path,
    *,
    files: Iterable[str],
    dirs: Iterable[str],
    allow_missing: bool,
) -> Tuple[Set[Path], List[str]]:
    candidates: Set[Path] = set()
    missing: List[str] = []

    for rel in files:
        path = root / rel
        if not path.exists():
            if allow_missing:
                continue
            missing.append(rel)
            continue
        if not path.is_file():
            if allow_missing:
                continue
            missing.append(rel)
            continue
        candidates.add(path)

    for rel in dirs:
        dir_path = root / rel
        if not dir_path.exists():
            if allow_missing:
                continue
            missing.append(rel)
            continue
        if not dir_path.is_dir():
            if allow_missing:
                continue
            missing.append(rel)
            continue
        for file_path in dir_path.rglob("*"):
            if file_path.is_file():
                candidates.add(file_path)

    return candidates, missing


def _validate_destination(root: Path, dest: Path) -> None:
    root_resolved = root.resolve()
    dest_resolved = dest.resolve()
    if dest_resolved == root_resolved:
        raise ValueError("Destination cannot be the same as repo root.")
    try:
        dest_resolved.relative_to(root_resolved)
        inside = True
    except ValueError:
        inside = False
    if inside:
        raise ValueError("Destination cannot be inside the source repo.")


def _copy_files(
    root: Path,
    dest: Path,
    files: Sequence[Path],
    *,
    dry_run: bool,
    verbose: bool,
) -> int:
    copied = 0
    for source in files:
        rel = source.relative_to(root)
        target = dest / rel
        if verbose or dry_run:
            print(f"{'WOULD COPY' if dry_run else 'COPY'} {rel.as_posix()}")
        if not dry_run:
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, target)
        copied += 1
    return copied


def main() -> int:
    args = _parse_args()
    repo_root = Path(__file__).resolve().parent
    dest = Path(args.dest).expanduser()
    profile = PROFILES[args.profile]

    try:
        _validate_destination(repo_root, dest)
    except ValueError as exc:
        print(f"[error] {exc}", file=sys.stderr)
        return 2

    candidates_required, missing_required = _collect_file_candidates(
        repo_root,
        files=profile.required_files,
        dirs=profile.required_dirs,
        allow_missing=args.allow_missing,
    )
    candidates_optional, _ = _collect_file_candidates(
        repo_root,
        files=profile.optional_files,
        dirs=profile.optional_dirs,
        allow_missing=True,
    )
    candidates = candidates_required | candidates_optional

    if missing_required and not args.allow_missing:
        print("[error] Missing required paths:", file=sys.stderr)
        for rel in missing_required:
            print(f"  - {rel}", file=sys.stderr)
        return 3

    exclude_patterns: List[str] = list(DEFAULT_EXCLUDE_PATTERNS) + list(args.exclude)
    kept: List[Path] = []
    excluded: List[Tuple[str, str]] = []
    for candidate in sorted(candidates, key=lambda p: _rel_posix(p, repo_root)):
        rel = _rel_posix(candidate, repo_root)
        match = _first_matching_pattern(rel, exclude_patterns)
        if match is not None:
            excluded.append((rel, match))
            continue
        kept.append(candidate)

    if args.clean and dest.exists() and not args.dry_run:
        shutil.rmtree(dest)
    if dest.exists() and any(dest.iterdir()) and not args.clean and not args.dry_run:
        print(
            f"[error] Destination exists and is not empty: {dest}\n"
            "Use --clean to replace it.",
            file=sys.stderr,
        )
        return 4
    if not args.dry_run:
        dest.mkdir(parents=True, exist_ok=True)

    copied_count = _copy_files(repo_root, dest, kept, dry_run=args.dry_run, verbose=args.verbose)
    copied_bytes = sum(p.stat().st_size for p in kept)

    summary = {
        "schema": "fullbleed.repo_fix_manifest.v1",
        "profile": args.profile,
        "source_root": str(repo_root),
        "dest_root": str(dest),
        "dry_run": bool(args.dry_run),
        "copied_file_count": copied_count,
        "copied_bytes": copied_bytes,
        "excluded_file_count": len(excluded),
        "excluded_preview": [
            {"path": rel, "pattern": pattern} for rel, pattern in excluded[:100]
        ],
        "missing_required": missing_required,
        "exclude_patterns": exclude_patterns,
    }
    if args.manifest_full:
        summary["files"] = [_rel_posix(path, repo_root) for path in kept]

    manifest_path = (
        Path(args.manifest).expanduser()
        if args.manifest
        else dest / "repo_fix_manifest.json"
    )
    if args.dry_run:
        print(json.dumps(summary, indent=2))
    else:
        manifest_path.parent.mkdir(parents=True, exist_ok=True)
        manifest_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
        print(
            f"[ok] Copied {copied_count} files ({copied_bytes} bytes) "
            f"to {dest}"
        )
        print(f"[ok] Manifest: {manifest_path}")

    return 0


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