"""Sauvegarde et restauration d'une liste de chemins relatifs à $HOME."""

from __future__ import annotations

import json
import os
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path

from osx_backup.context import Context
from osx_backup.fsutil import copy_tree, remove_path, tree_size
from osx_backup.mapfile import GLOBAL_EXCLUDES
from osx_backup.modules.base import Inventory, ModuleResult, ProgressReporter

ENTRIES_FILE = "_entries.json"


@dataclass(frozen=True)
class PathItem:
    rel: str
    excludes: tuple[str, ...] = ()

    @property
    def all_excludes(self) -> list[str]:
        return GLOBAL_EXCLUDES + list(self.excludes)


def safe_join(root: Path, rel: str) -> Path | None:
    """`root / rel` s'il reste sous `root`, sinon None.

    Ceinture et bretelles derrière la validation de `map.toml` : un `rel` absolu fait de
    `root / rel` le fichier réel, et `..` s'échappe du dossier. `normpath` est lexical,
    donc utilisable sur un chemin qui n'existe pas encore.
    """
    target = Path(os.path.normpath(root / rel))
    return target if target.is_relative_to(root) else None


def inventory_paths(ctx: Context, items: Sequence[PathItem]) -> Inventory:
    count = size = 0
    warnings: list[str] = []
    for item in items:
        src = ctx.home / item.rel
        if not src.exists():
            warnings.append(f"absent : ~/{item.rel}")
            continue
        _, s = tree_size(src, item.all_excludes)
        count += 1
        size += s
    return Inventory(items=count, size_bytes=size, warnings=warnings)


def backup_paths(
    ctx: Context, module_name: str, items: Sequence[PathItem], progress: ProgressReporter
) -> ModuleResult:
    out = ctx.module_dir(module_name)
    warnings: list[str] = []
    safe: list[PathItem] = []
    for item in items:
        if safe_join(out, item.rel) is None or safe_join(ctx.home, item.rel) is None:
            message = f"chemin hors du dossier de sauvegarde, entrée ignorée : {item.rel}"
            warnings.append(message)
            ctx.warn(message)
            continue
        safe.append(item)
    present = [i for i in safe if (ctx.home / i.rel).exists()]
    task = progress.task(module_name, total=len(present))
    total = 0
    copied: list[str] = []
    for item in present:
        src, dst = ctx.home / item.rel, out / item.rel
        if ctx.dry_run:
            ctx.log(f"[dry-run] copy {src} -> {dst}")
            copied.append(item.rel)
        else:
            # Une entrée illisible (permissions, fichier volatil) ne doit pas faire tomber
            # tout le module : sans _entries.json, la restauration croirait la sauvegarde
            # vide alors que les autres entrées ont bien été copiées.
            try:
                remove_path(dst)
                total += copy_tree(src, dst, item.all_excludes)
            except OSError as exc:
                message = f"copie impossible, entrée ignorée : ~/{item.rel} ({exc})"
                warnings.append(message)
                ctx.warn(message)
            else:
                copied.append(item.rel)
        task.advance()
    task.finish()
    ctx.write_json(out / ENTRIES_FILE, copied)
    if present and not copied:
        return ModuleResult.failed(
            f"aucune des {len(present)} entrées n'a pu être copiée", warnings=warnings
        )
    return ModuleResult.ok(
        f"{len(copied)} entrées copiées", items=len(copied), size_bytes=total, warnings=warnings
    )


def restore_paths(ctx: Context, module_name: str, progress: ProgressReporter) -> ModuleResult:
    src_root = ctx.dest / module_name
    entries_file = src_root / ENTRIES_FILE
    if not entries_file.exists():
        return ModuleResult.skipped(f"aucune donnée {module_name} dans la sauvegarde")
    rels: list[str] = json.loads(entries_file.read_text(encoding="utf-8"))
    task = progress.task(module_name, total=len(rels))
    total = restored = 0
    warnings: list[str] = []
    for rel in rels:
        if safe_join(ctx.home, rel) is None or safe_join(src_root, rel) is None:
            message = f"chemin hors de $HOME, entrée ignorée : {rel}"
            warnings.append(message)
            ctx.warn(message)
            task.advance()
            continue
        src = src_root / rel
        # place() refuse déjà de toucher à $HOME quand la source manque, mais en levant
        # FileNotFoundError : sans cette garde, une entrée absente de la sauvegarde
        # avorterait la restauration des entrées suivantes.
        if src.exists() or src.is_symlink():
            total += ctx.place(src, ctx.home / rel)
            restored += 1
        else:
            message = f"absent de la sauvegarde, ignoré : {module_name}/{rel}"
            warnings.append(message)
            ctx.warn(message)
        task.advance()
    task.finish()
    return ModuleResult.ok(
        f"{restored} entrées restaurées", items=restored, size_bytes=total, warnings=warnings
    )
