"""push で送るファイルの選定（除外規則・追加指定・サーバ側の削除対象の算出）．

既定では，Windows 専用の仮想環境・計算結果・キャッシュ・スイープの一時生成物を除いた
プロジェクト全体を送る．gitignore 対象でも実測参照データ（lightsim/・reference_measurement/・
REFERENCE/）はサーバでの評価に要るので送る．
"""

from __future__ import annotations

import fnmatch
import os
import re
from collections.abc import Callable, Collection, Iterable, Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

# どの階層にあっても送らないディレクトリ名（.venv は Windows 用でサーバでは使えない）
EXCLUDED_DIR_NAMES = frozenset({".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"})
# プロジェクト直下でだけ除外するエントリ（サーバ側の計算結果を上書きしないため output/ は送らない）
EXCLUDED_ROOT_ENTRIES = frozenset({"output"})
# プロジェクトルート基準のパターン（スイープ生成物．約 450 MB あり必要時だけ --include で送る）
EXCLUDED_GLOBS = ("configs/_tmp_*",)
# push --prune でサーバ側の古いファイルを消してよいディレクトリ（コードのみ．output/・configs/ は
# サーバ側で生成されたファイルがありうるので対象外）
PRUNE_DIRS = ("src", "tests", "scripts")

# シーン YAML の `path: C:\...` / `- path: 'D:/...'` のような Windows 絶対パス
_WINDOWS_ABS_PATH_RE = re.compile(r"^\s*(?:-\s*)?path:\s*['\"]?[A-Za-z]:[\\/]", re.MULTILINE)


@dataclass(frozen=True)
class PushEntry:
    """送信するファイル 1 件．

    Attributes:
        path: 手元の実ファイル．
        arcname: リモートのプロジェクトルート基準の相対パス（POSIX 区切り）．
        size: バイト数（進捗表示用）．
    """

    path: Path
    arcname: str
    size: int


def find_project_root(start: Path) -> Path:
    """start から上方向にたどり，SmTIAS-LightSim のプロジェクトルートを探す．

    Args:
        start: 探索を始めるディレクトリ（通常はカレントディレクトリ）．

    Returns:
        pyproject.toml と src/sm_tias_lightsim/ を持つディレクトリ．

    Raises:
        ValueError: 見つからない場合．
    """
    start = start.resolve()
    for directory in (start, *start.parents):
        if (directory / "pyproject.toml").is_file() and (
            directory / "src" / "sm_tias_lightsim"
        ).is_dir():
            return directory
    msg = (
        f"{start} から上にプロジェクトルート（pyproject.toml と src/sm_tias_lightsim/）が"
        "見つかりません．リポジトリ内で実行するか --local-dir / --dest で指定してください．"
    )
    raise ValueError(msg)


def _is_cache_path(rel_path: str) -> bool:
    """パスのどこかに除外ディレクトリ名（キャッシュ・.git 等）を含むか．"""
    return any(part in EXCLUDED_DIR_NAMES for part in PurePosixPath(rel_path).parts)


def is_excluded(rel_path: str) -> bool:
    """プロジェクトルート基準の相対パスが既定の push から除外されるかを返す．

    Args:
        rel_path: POSIX 区切りの相対パス（ファイルでもディレクトリでもよい）．

    Returns:
        除外対象なら True．
    """
    parts = PurePosixPath(rel_path).parts
    if not parts:
        return False
    if _is_cache_path(rel_path) or parts[0] in EXCLUDED_ROOT_ENTRIES:
        return True
    prefixes = ("/".join(parts[:i]) for i in range(1, len(parts) + 1))
    return any(
        fnmatch.fnmatchcase(prefix, pattern) for prefix in prefixes for pattern in EXCLUDED_GLOBS
    )


def _walk_files(root: Path, start: Path, skip: Callable[[str], bool]) -> Iterator[PushEntry]:
    """start 配下のファイルを，skip で除外しつつ決定的な順で列挙する．"""
    for dirpath, dirnames, filenames in os.walk(start):
        dir_rel = Path(dirpath).relative_to(root).as_posix()
        prefix = "" if dir_rel == "." else f"{dir_rel}/"
        dirnames[:] = sorted(d for d in dirnames if not skip(prefix + d))
        for filename in sorted(filenames):
            rel = prefix + filename
            path = Path(dirpath) / filename
            if skip(rel) or not path.is_file():
                continue
            yield PushEntry(path=path, arcname=rel, size=path.stat().st_size)


def resolve_extra_path(root: Path, text: str) -> tuple[Path, str]:
    """--include / --only で指定されたパスを検証し，実体と相対パスを返す．

    Args:
        root: プロジェクトルート（解決済み）．
        text: 指定されたパス（相対ならプロジェクトルート基準）．

    Returns:
        (実体の絶対パス, プロジェクトルート基準の POSIX 相対パス)．

    Raises:
        ValueError: プロジェクト外・プロジェクト全体・output/ 配下・存在しない場合．
    """
    candidate = Path(text)
    if not candidate.is_absolute():
        candidate = root / candidate
    resolved = candidate.resolve()
    try:
        rel = resolved.relative_to(root)
    except ValueError as exc:
        raise ValueError(f"プロジェクト外のパスは送れません: {text}") from exc
    if not rel.parts:
        msg = "プロジェクト全体は --include / --only ではなく通常の push で送ってください．"
        raise ValueError(msg)
    if rel.parts[0] in EXCLUDED_ROOT_ENTRIES:
        msg = f"output/ 配下は push できません（サーバ側の計算結果を上書きしうるため）: {text}"
        raise ValueError(msg)
    if not resolved.exists():
        raise ValueError(f"パスが存在しません: {text}")
    return resolved, rel.as_posix()


def collect_push_entries(
    root: Path, *, include_base: bool, extra_paths: Sequence[str] = ()
) -> list[PushEntry]:
    """送信するファイルの一覧を作る．

    Args:
        root: プロジェクトルート．
        include_base: True なら既定の除外規則を適用したプロジェクト全体を含める．
        extra_paths: 追加で送るパス（configs/_tmp_* 等）．既定の除外規則のうち
            グロブ（configs/_tmp_*）は適用しないが，キャッシュ類は除く．

    Returns:
        arcname 順に並べた送信対象（同じ arcname は 1 件にまとめる）．

    Raises:
        ValueError: extra_paths に不正なパスがある場合．
    """
    root = root.resolve()
    entries: dict[str, PushEntry] = {}
    if include_base:
        for entry in _walk_files(root, root, is_excluded):
            entries[entry.arcname] = entry
    for text in extra_paths:
        path, rel = resolve_extra_path(root, text)
        if path.is_file():
            entries[rel] = PushEntry(path=path, arcname=rel, size=path.stat().st_size)
            continue
        for entry in _walk_files(root, path, _is_cache_path):
            entries[entry.arcname] = entry
    return [entries[name] for name in sorted(entries)]


def find_yaml_with_windows_paths(entries: Iterable[PushEntry]) -> list[str]:
    """メッシュ等の path に Windows の絶対パスを含む YAML を返す（サーバでは読めない）．

    configs/_tmp_*/ の古い生成物は絶対パスで書かれているため，送る前に警告する目的．
    読めないファイルは判定対象外にする（警告用の補助チェックで，送信自体は妨げない）．

    Args:
        entries: 送信対象．

    Returns:
        該当する YAML の arcname 一覧．
    """
    hits: list[str] = []
    for entry in entries:
        if not entry.arcname.endswith((".yaml", ".yml")):
            continue
        try:
            text = entry.path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        if _WINDOWS_ABS_PATH_RE.search(text):
            hits.append(entry.arcname)
    return hits


def compute_prune_targets(
    remote_files: Iterable[str],
    local_arcnames: Collection[str],
    prune_dirs: Sequence[str] = PRUNE_DIRS,
) -> list[str]:
    """サーバにあって手元に無い（＝削除・改名された）ファイルを求める．

    Args:
        remote_files: サーバの prune_dirs 配下のファイル一覧（プロジェクトルート基準）．
        local_arcnames: 手元から送ったファイルの arcname 集合．
        prune_dirs: 削除してよいディレクトリ．

    Returns:
        削除対象の相対パス（ソート済み）．キャッシュ類は含めない．

    Raises:
        RuntimeError: prune_dirs 外・絶対パス・`..` を含むパスを受け取った場合
            （リモートの一覧コマンドの不変条件違反．誤削除を防ぐため中断する）．
    """
    local = set(local_arcnames)
    targets: list[str] = []
    for rel in remote_files:
        path = PurePosixPath(rel)
        if (
            path.is_absolute()
            or ".." in path.parts
            or len(path.parts) < 2
            or path.parts[0] not in prune_dirs
        ):
            raise RuntimeError(f"想定外のリモートパスを受信しました（削除を中止）: {rel!r}")
        normalized = path.as_posix()
        if _is_cache_path(normalized) or normalized in local:
            continue
        targets.append(normalized)
    return sorted(targets)
