"""tar ストリームの書き出し（push）と，回収した tar の検査・展開（pull）.

Windows には rsync が無いため，Python の tarfile でストリームを作り `ssh ... 'tar -x ...'` の
標準入力へ流す（push）．pull は ssh の標準出力をいったん一時ファイルに受け取り，
全メンバーを検査してから展開する（ストリームのまま展開すると，問題のメンバーに当たる前の
ファイルが展開済みになってしまうため．SPEC_03「pull」の二重の防御）．
"""

from __future__ import annotations

import tarfile
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO

from tias_judge_model.remote.files import PushEntry
from tias_judge_model.remote.whitelist import check_pull_members

_ENCODING = "utf-8"
_COPY_CHUNK = 1024 * 1024


@dataclass(frozen=True)
class ExtractResult:
    """展開の結果.

    Attributes:
        files: 展開したファイル数．
        bytes: 展開したファイルの合計バイト数．
    """

    files: int
    bytes: int


def _normalize_tarinfo(info: tarfile.TarInfo) -> tarfile.TarInfo:
    """所有者とパーミッションを送信元 OS に依存しない値にそろえる.

    Windows の stat は所有者を持たず実行ビットも拡張子から推測した値なので，
    展開結果がそれに引きずられないよう固定する（実行ビットが立っているものだけ 755）．
    """
    info.uid = info.gid = 0
    info.uname = info.gname = ""
    info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644
    return info


def write_tar_stream(entries: Sequence[PushEntry], fileobj: BinaryIO) -> int:
    """送信対象を非圧縮の tar ストリームとして fileobj へ書き出す.

    PAX 形式で書く（日本語ファイル名を UTF-8 で確実に運ぶ）．fileobj は閉じない
    （呼び出し側が ssh の標準入力を閉じて EOF を伝える）．

    Args:
        entries: 送信対象．
        fileobj: 書き込み先のバイナリストリーム（ssh の標準入力）．

    Returns:
        書き出したファイルの合計バイト数．

    Raises:
        OSError: 書き込み先が閉じられた（ssh が異常終了した）場合など．
    """
    done = 0
    with tarfile.open(
        fileobj=fileobj, mode="w|", format=tarfile.PAX_FORMAT, encoding=_ENCODING, dereference=True
    ) as tar:
        for entry in entries:
            tar.add(entry.path, arcname=entry.arcname, recursive=False, filter=_normalize_tarinfo)
            done += entry.size
    return done


def copy_stream(source: BinaryIO, destination: BinaryIO) -> int:
    """source を終端まで読み，destination へ書き写す.

    Args:
        source: 読み出し元（ssh の標準出力）．
        destination: 書き込み先（一時ファイル）．

    Returns:
        書き写したバイト数．
    """
    total = 0
    while chunk := source.read(_COPY_CHUNK):
        destination.write(chunk)
        total += len(chunk)
    return total


def extract_pull_archive(
    archive: BinaryIO, dest: Path, run_id: str, *, with_checkpoints: bool
) -> ExtractResult:
    """回収した tar（シーク可能なファイル）の全メンバーを検査し，問題が無ければ dest へ展開する.

    検査（`check_pull_members`）で 1 つでも問題があれば，何も展開せずに例外を送出する．
    検査を通ったメンバーは相対パスの通常ファイルのみだが，念のため Python 3.12 の
    `filter="data"`（展開先の外・特殊ファイル・危険なパーミッションを拒否）も重ねる．

    Args:
        archive: tar を書き込んだ一時ファイル（読み出し位置は問わない）．
        dest: 展開先（手元の `runs/`．`<RUN_ID>/...` がその下にできる．同名ファイルは上書き）．
        run_id: 検証済みの RUN_ID．
        with_checkpoints: `checkpoints/*.pt` を受け入れるか．

    Returns:
        展開したファイル数と合計バイト数．

    Raises:
        ValueError: 回収できないメンバーが含まれる場合（何も展開しない）．
        tarfile.TarError: アーカイブが壊れている場合（何も展開しない）．
        OSError: 書き込みに失敗した場合．
    """
    archive.seek(0)
    with tarfile.open(fileobj=archive, mode="r:", encoding=_ENCODING) as tar:
        members = tar.getmembers()
        check_pull_members(members, run_id, with_checkpoints=with_checkpoints)
        dest.mkdir(parents=True, exist_ok=True)
        tar.extractall(dest, members=members, filter="data")
    return ExtractResult(files=len(members), bytes=sum(member.size for member in members))
