"""Copie filtrée, tailles, exclusions glob, déplacement de l'existant."""

from __future__ import annotations

import os
import shutil
from collections.abc import Sequence
from fnmatch import fnmatch
from pathlib import Path

DEPENDENCY_DIRS: list[str] = [
    "node_modules",
    ".venv",
    "venv",
    "__pycache__",
    ".pytest_cache",
    ".mypy_cache",
    ".ruff_cache",
    ".next",
    ".nuxt",
    ".svelte-kit",
    ".output",
    ".turbo",
    ".cache",
    ".parcel-cache",
    "dist",
    "build",
    "target",
]


def _segments_match_at(parts: Sequence[str], segments: Sequence[str], start: int) -> bool:
    """Les segments du motif matchent-ils parts à partir de l'index start ?"""
    if start + len(segments) > len(parts):
        return False
    return all(fnmatch(parts[start + i], seg) for i, seg in enumerate(segments))


def is_excluded(rel: Path, patterns: Sequence[str]) -> bool:
    """rel (chemin relatif à la racine copiée) est-il exclu par l'un des motifs ?

    Trois régimes, chaque segment étant comparé avec ``fnmatch`` :

    - motif sans ``/`` (``node_modules``, ``*.log``) : matche **n'importe quel segment**,
      à n'importe quelle profondeur — ``cache`` exclut ``cache``, ``plugins/cache`` et
      ``plugins/foo/cache`` ;
    - motif préfixé de ``**/`` (``**/foo/bar``) : matche **la séquence de segments à
      n'importe quelle profondeur** — ``**/foo/bar`` exclut ``foo/bar`` comme ``x/foo/bar`` ;
    - motif contenant ``/`` sans préfixe ``**/`` (``plugins/cache``) : **ancré à la racine**,
      il matche les segments de tête — ``plugins/cache`` exclut ``plugins/cache`` mais pas
      ``x/plugins/cache``.

    Dans tous les cas un chemin **sous** une correspondance est exclu lui aussi
    (``plugins/cache`` exclut ``plugins/cache/deep/file``) : exclure un dossier exclut
    son contenu.
    """
    parts = rel.parts
    for pattern in patterns:
        if pattern.startswith("**/"):
            segments = pattern[3:].split("/")
            starts = range(len(parts))
        elif "/" in pattern:
            segments = pattern.split("/")
            starts = range(1)
        else:
            segments = [pattern]
            starts = range(len(parts))
        if any(_segments_match_at(parts, segments, start) for start in starts):
            return True
    return False


def _copy_symlink(src: Path, dst: Path) -> None:
    if dst.is_symlink() or dst.exists():
        if dst.is_dir() and not dst.is_symlink():
            shutil.rmtree(dst)
        else:
            dst.unlink()
    os.symlink(os.readlink(src), dst)


def copy_tree(src: Path, dst: Path, excludes: Sequence[str] = ()) -> int:
    """Copie src (fichier ou dossier) vers dst en appliquant les exclusions. Retourne les octets.

    Lève ``FileNotFoundError`` si src n'existe pas : une copie silencieusement vide
    ferait perdre le fichier que ``Context.place`` vient de mettre de côté.
    """
    if not src.exists() and not src.is_symlink():
        raise FileNotFoundError(f"source introuvable : {src}")
    if src.is_symlink() and not src.is_dir():
        dst.parent.mkdir(parents=True, exist_ok=True)
        _copy_symlink(src, dst)
        return 0
    if src.is_file():
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        return src.stat().st_size
    total = 0
    for root, dirs, files in os.walk(src):
        root_p = Path(root)
        rel_root = root_p.relative_to(src)
        kept: list[str] = []
        for d in dirs:
            rel = rel_root / d
            if is_excluded(rel, excludes):
                continue
            if (root_p / d).is_symlink():
                (dst / rel_root).mkdir(parents=True, exist_ok=True)
                _copy_symlink(root_p / d, dst / rel)
                continue
            kept.append(d)
        dirs[:] = kept
        (dst / rel_root).mkdir(parents=True, exist_ok=True)
        for f in files:
            rel = rel_root / f
            if is_excluded(rel, excludes):
                continue
            sp, dp = root_p / f, dst / rel
            if sp.is_symlink():
                _copy_symlink(sp, dp)
                continue
            shutil.copy2(sp, dp)
            total += sp.stat().st_size
    return total


def tree_size(src: Path, excludes: Sequence[str] = ()) -> tuple[int, int]:
    """(nombre de fichiers, octets) de src après exclusions. Symlinks comptés 0 octet."""
    if src.is_file():
        return 1, src.stat().st_size
    files_count = total = 0
    for root, dirs, files in os.walk(src):
        rel_root = Path(root).relative_to(src)
        dirs[:] = [d for d in dirs if not is_excluded(rel_root / d, excludes)]
        for f in files:
            if is_excluded(rel_root / f, excludes):
                continue
            p = Path(root) / f
            files_count += 1
            if not p.is_symlink():
                total += p.stat().st_size
    return files_count, total


def _free_target(target: Path) -> Path:
    """target, ou target.1, target.2… si target est déjà pris.

    Sans ce suffixe, deux mises de côté du même chemin dans un seul run écraseraient la
    première (fichier) ou s'imbriqueraient dedans (dossier) : l'original serait perdu.
    """
    if not target.exists() and not target.is_symlink():
        return target
    n = 1
    while True:
        candidate = target.with_name(f"{target.name}.{n}")
        if not candidate.exists() and not candidate.is_symlink():
            return candidate
        n += 1


def move_aside(path: Path, aside_root: Path, home: Path) -> Path | None:
    """Déplace path (s'il existe) sous aside_root en conservant son chemin relatif à home."""
    if not path.exists() and not path.is_symlink():
        return None
    rel = path.relative_to(home) if path.is_relative_to(home) else Path(path.name)
    target = _free_target(aside_root / rel)
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.move(str(path), str(target))
    return target


def format_size(n: int) -> str:
    if n < 1024:
        return f"{n} B"
    value = n / 1024
    for unit in ("KB", "MB", "GB"):
        if value < 1024:
            return f"{value:.1f} {unit}"
        value /= 1024
    return f"{value:.1f} TB"


def remove_path(path: Path) -> None:
    """Supprime path — fichier, symlink ou dossier. Silencieux si absent."""
    if path.is_symlink() or path.is_file():
        path.unlink()
    elif path.is_dir():
        shutil.rmtree(path)
