"""研究室サーバ上で実行するシェルコマンド文字列を組み立てる純関数群．

`ssh <host> '<command>'` で送る文字列を作る．リモートのログインシェル（Ubuntu では bash）が
解釈する前提で POSIX sh の範囲で書き，ジョブ本体だけは `${PIPESTATUS[0]}` を使うため
`bash -c` で包む．ユーザー由来の値（パス・コマンド引数）はすべて `shlex.quote` でクォートし，
ジョブ名・GPU 指定・環境変数名は書式を検証してから埋め込む．

Windows から ssh へ渡す引数に改行を含めないため，複数の文は `; ` で 1 行につなぐ．
リモート側が出すメッセージは ASCII に限る（ssh の stderr は Windows コンソールへ素通しされ，
コードページによっては日本語が化けるため．日本語の説明は呼び出し側の CLI が出す）．
"""

from __future__ import annotations

import re
import shlex
from collections.abc import Mapping, Sequence
from pathlib import PurePosixPath

REMOTE_OUTPUT_DIR = "output"
REMOTE_LOG_DIR = "output/_remote_logs"

# リモートスクリプトが返す終了コード．ssh 自身の 255・tar の 1/2 と衝突しない値にする
EXIT_REMOTE_DIR_MISSING = 90
EXIT_SESSION_EXISTS = 91
EXIT_NOT_FOUND = 92
EXIT_NO_SESSION = 93

# tmux のセッション名に使えない "." ":" を避け，ログのファイル名にもそのまま使える文字に限る
_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
_GPU_SPEC_RE = re.compile(r"^\d+(,\d+)*$")
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")

_NOW = "\"$(date '+%Y-%m-%d %H:%M:%S')\""
_STATUS_LOG_COUNT = 10


def quote_remote_path(path: str) -> str:
    """リモートのパスをシェル用にクォートする．

    先頭の `~` はリモートの `$HOME` に展開させる必要があるため，`~/...` は
    `"$HOME"/'...'` の形に変換する（丸ごとクォートするとチルダ展開が起きない）．

    Args:
        path: リモートのパス（`~/...`・絶対パス・ホーム基準の相対パス）．

    Returns:
        シェルにそのまま埋め込める文字列．

    Raises:
        ValueError: 空文字，または `~ユーザー名/...` 形式の場合．
    """
    if not path or not path.strip():
        raise ValueError("リモートパスが空です．")
    if path == "~":
        return '"$HOME"'
    if path.startswith("~/"):
        rest = path[2:]
        return f'"$HOME"/{shlex.quote(rest)}' if rest else '"$HOME"'
    if path.startswith("~"):
        msg = (
            f"~ユーザー名 形式のリモートパスには対応していません: {path!r}"
            "（~/... か絶対パスを指定）"
        )
        raise ValueError(msg)
    return shlex.quote(path)


def validate_host(host: str) -> str:
    """SSH ホスト名を検証して返す（ssh のオプションとして解釈されるのを防ぐ）．

    Args:
        host: `~/.ssh/config` の Host 名，またはホスト名・IP．

    Returns:
        検証済みのホスト名．

    Raises:
        ValueError: 空・`-` 始まり・空白を含む場合．
    """
    if not host or host.startswith("-") or any(c.isspace() for c in host):
        raise ValueError(f"SSH ホスト名が不正です: {host!r}")
    return host


def validate_job_name(name: str) -> str:
    """ジョブ名（tmux セッション名・ログのファイル名）を検証して返す．

    Args:
        name: ジョブ名．

    Returns:
        検証済みのジョブ名．

    Raises:
        ValueError: 英数字で始まり英数字・`_`・`-` のみ（64 文字以内）でない場合．
    """
    if not _JOB_NAME_RE.match(name):
        msg = f"ジョブ名が不正です: {name!r}（英数字で始まり，英数字・_・- のみ，64 文字以内）"
        raise ValueError(msg)
    return name


def validate_gpu_spec(spec: str) -> str:
    """CUDA_VISIBLE_DEVICES に渡す GPU 番号の指定を検証して返す．

    Args:
        spec: `0` や `0,2` のようなカンマ区切りの GPU 番号．

    Returns:
        検証済みの指定文字列．

    Raises:
        ValueError: 書式が不正な場合．
    """
    if not _GPU_SPEC_RE.match(spec):
        raise ValueError(f"GPU 指定が不正です: {spec!r}（例: 0 / 0,2）")
    return spec


def parse_env_assignment(text: str) -> tuple[str, str]:
    """`KEY=VALUE` 形式の環境変数指定を分解する．

    Args:
        text: `KEY=VALUE` 形式の文字列（VALUE は空でもよい）．

    Returns:
        (KEY, VALUE)．

    Raises:
        ValueError: `=` が無い，または KEY が環境変数名として不正な場合．
    """
    key, sep, value = text.partition("=")
    if not sep or not _ENV_KEY_RE.match(key):
        raise ValueError(f"環境変数の指定が不正です: {text!r}（KEY=VALUE 形式）")
    return key, value


def build_ssh_argv(ssh: str, host: str, remote_command: str, *, tty: bool = False) -> list[str]:
    """ssh を起動する引数リストを返す．

    Args:
        ssh: ssh 実行ファイル．
        host: 接続先ホスト．
        remote_command: リモートで実行するシェルコマンド（1 引数として渡す）．
        tty: True なら `-t` で疑似端末を割り当てる（tmux attach 用）．

    Returns:
        subprocess に渡す引数リスト．
    """
    argv = [ssh]
    if tty:
        argv.append("-t")
    argv += [validate_host(host), remote_command]
    return argv


def _cd_command(remote_dir: str) -> str:
    """リモートディレクトリへ移動し，無ければ専用の終了コードで抜ける文を返す．"""
    quoted = quote_remote_path(remote_dir)
    return (
        f"cd {quoted} || {{ echo 'sm-remote: remote directory not found:' {quoted} >&2; "
        f"exit {EXIT_REMOTE_DIR_MISSING}; }}"
    )


def _job_log_path(name: str) -> str:
    """ジョブログのパス（プロジェクトルート基準．クォート前）を返す．name は検証済みとする．"""
    return f"{REMOTE_LOG_DIR}/{name}.log"


def _tmux_target(name: str) -> str:
    """tmux の `-t` に渡すセッション指定をクォートして返す．name は検証済みとする．

    `=` を前置して完全一致にする（付けないと tmux は前方一致で別のジョブを選びうる）．
    """
    return shlex.quote(f"={name}")


def build_push_command(remote_dir: str, *, compress: bool) -> str:
    """標準入力の tar ストリームをリモートディレクトリへ展開するコマンドを返す．

    展開前に tmux セッションが動いていれば警告を出す（実行中のジョブが途中から
    新しいコードを読み込み，結果が混ざるおそれがあるため）．

    Args:
        remote_dir: 展開先（リモートのプロジェクトルート）．
        compress: True なら gzip 圧縮ストリームとして展開する．

    Returns:
        リモートで実行するシェルコマンド．
    """
    quoted = quote_remote_path(remote_dir)
    flags = "-xzf" if compress else "-xf"
    warn = (
        "if tmux ls >/dev/null 2>&1; then "
        "echo 'sm-remote: warning: tmux sessions are running"
        " (running jobs may pick up the new code):' >&2; tmux ls >&2; fi"
    )
    return f"{warn}; mkdir -p {quoted} && tar {flags} - -C {quoted}"


def build_list_files_command(
    remote_dir: str, dirs: Sequence[str], skip_dir_names: Sequence[str]
) -> str:
    """リモートの指定ディレクトリ配下のファイル一覧を NUL 区切りで出力するコマンドを返す．

    Args:
        remote_dir: リモートのプロジェクトルート．
        dirs: 一覧を取るディレクトリ（プロジェクトルート基準）．
        skip_dir_names: 探索しないディレクトリ名（`__pycache__` 等）．

    Returns:
        リモートで実行するシェルコマンド．
    """
    names = " ".join(shlex.quote(d) for d in dirs)
    prune = " -o ".join(f"-name {shlex.quote(n)}" for n in skip_dir_names)
    return (
        f"{_cd_command(remote_dir)}; for d in {names}; do "
        f'if [ -d "$d" ]; then find "$d" -type d \\( {prune} \\) -prune -o -type f -print0; fi; '
        "done"
    )


def build_remove_files_command(remote_dir: str) -> str:
    """標準入力の NUL 区切りパス一覧をリモートで削除するコマンドを返す．

    Args:
        remote_dir: リモートのプロジェクトルート（パスはこの基準の相対パス）．

    Returns:
        リモートで実行するシェルコマンド．
    """
    return f"{_cd_command(remote_dir)}; xargs -0 rm -f --"


def build_job_script(
    remote_dir: str,
    name: str,
    command: Sequence[str],
    *,
    uv_path: str,
    use_uv: bool,
    env: Mapping[str, str],
) -> str:
    """tmux セッション内で bash が実行するジョブ本体のスクリプトを返す．

    出力は `output/_remote_logs/<name>.log` に追記し（前回の同名ジョブのログも残す），
    開始行と終了コード行で挟む．標準入力は /dev/null にする（入力待ちで止まらないように）．
    uv のディレクトリを PATH に足すのは，スイープスクリプトが `uv run sm-render` を
    名前で呼ぶため（非ログインの ssh では ~/.local/bin が PATH に入らない）．

    Args:
        remote_dir: リモートのプロジェクトルート．
        name: ジョブ名．
        command: 実行するコマンドと引数．
        uv_path: リモートの uv 実行ファイル．
        use_uv: True なら `uv run` を前置する．
        env: ジョブに渡す環境変数．

    Returns:
        bash スクリプト（1 行）．

    Raises:
        ValueError: コマンドが空，またはジョブ名・環境変数名が不正な場合．
    """
    if not command:
        raise ValueError("実行するコマンドが空です．")
    log = _job_log_path(validate_job_name(name))
    for key in env:
        if not _ENV_KEY_RE.match(key):
            raise ValueError(f"環境変数名が不正です: {key!r}")

    uv_quoted = quote_remote_path(uv_path)
    run_argv = ([uv_quoted, "run"] if use_uv else []) + [shlex.quote(arg) for arg in command]
    display = shlex.join(
        [f"{k}={v}" for k, v in env.items()] + (["uv", "run"] if use_uv else []) + list(command)
    )
    statements = [
        _cd_command(remote_dir),
        f"log={shlex.quote(log)}",
        f"printf '=== [%s] start on %s: %s\\n' {_NOW} \"$(hostname)\" {shlex.quote(display)}"
        ' >> "$log"',
        f'export PATH="$(dirname -- {uv_quoted}):$PATH"',
        *[f"export {k}={shlex.quote(v)}" for k, v in env.items()],
        f'{" ".join(run_argv)} < /dev/null 2>&1 | tee -a "$log"',
        "rc=${PIPESTATUS[0]}",
        f'printf \'=== [%s] exit %s\\n\' {_NOW} "$rc" >> "$log"',
        'exit "$rc"',
    ]
    return "; ".join(statements)


def build_run_command(remote_dir: str, name: str, job_script: str) -> str:
    """ジョブ本体を tmux の新規セッション（デタッチ状態）で起動するコマンドを返す．

    同名セッションが既にあれば起動せず `EXIT_SESSION_EXISTS` で終わる．
    tmux のサーバはデーモン化して ssh の入出力を閉じるため，ssh はすぐ戻り，
    接続が切れてもジョブは継続する．

    Args:
        remote_dir: リモートのプロジェクトルート．
        name: ジョブ名（tmux セッション名）．
        job_script: `build_job_script` が返したスクリプト．

    Returns:
        リモートで実行するシェルコマンド．
    """
    name = validate_job_name(name)
    target = _tmux_target(name)
    inner = "bash -c " + shlex.quote(job_script)
    statements = [
        _cd_command(remote_dir),
        f"mkdir -p {REMOTE_LOG_DIR} || exit 1",
        f"if tmux has-session -t {target} 2>/dev/null; then "
        f"echo 'sm-remote: tmux session already exists:' {shlex.quote(name)} >&2; "
        f"exit {EXIT_SESSION_EXISTS}; fi",
        f'tmux new-session -d -s {shlex.quote(name)} -c "$PWD" {shlex.quote(inner)}',
    ]
    return "; ".join(statements)


def _last_line_command(path_expr: str) -> str:
    """ログの最後の 1 行を表示するパイプラインを返す．

    mitsuba の進捗バーは \\r で上書きするので，最後の \\r 以降だけを 1 行として見せる．
    """
    return f"tail -n 1 {path_expr} | tr '\\r' '\\n' | grep -v '^$' | tail -n 1 | cut -c1-100"


def build_status_command(remote_dir: str) -> str:
    """tmux セッション・最近のジョブログ・GPU・負荷を表示するコマンドを返す．

    Args:
        remote_dir: リモートのプロジェクトルート．

    Returns:
        リモートで実行するシェルコマンド．
    """
    last_line = _last_line_command('"$f"')
    log_loop = (
        f"for f in $(ls -t {REMOTE_LOG_DIR}/*.log 2>/dev/null | head -n {_STATUS_LOG_COUNT}); do "
        'n=$(basename "$f" .log); '
        'if tmux has-session -t "=$n" 2>/dev/null; then s=RUNNING; else s=ended; fi; '
        f"last=$({last_line}); "
        'printf \'%-28s %-8s %s\\n\' "$n" "$s" "$last"; '
        "done"
    )
    gpu = (
        "if command -v nvidia-smi >/dev/null 2>&1; then "
        "nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total"
        " --format=csv,noheader; "
        "else echo '(nvidia-smi not found)'; fi"
    )
    statements = [
        _cd_command(remote_dir),
        "echo '== tmux sessions (running jobs) =='",
        "tmux ls 2>/dev/null || echo '(none)'",
        "echo",
        f"echo '== recent job logs ({REMOTE_LOG_DIR}) =='",
        log_loop,
        "echo",
        "echo '== GPU (index, name, util, mem used, mem total) =='",
        gpu,
        "echo",
        "echo '== load average =='",
        "uptime",
    ]
    return "; ".join(statements)


def build_logs_command(remote_dir: str, name: str, *, lines: int, follow: bool) -> str:
    """ジョブログの末尾を表示する（follow なら追従する）コマンドを返す．

    追従モードでは，ssh の標準入力が閉じたら（手元で Ctrl+C・終了したら）tail を止める．
    疑似端末なしの ssh では切断しても tail に SIGHUP が届かず，ログが伸びない限り
    リモートに tail が残り続けるため．

    Args:
        remote_dir: リモートのプロジェクトルート．
        name: ジョブ名．
        lines: 表示する末尾の行数．
        follow: True なら追記を追従する．

    Returns:
        リモートで実行するシェルコマンド．
    """
    log = shlex.quote(_job_log_path(validate_job_name(name)))
    check = (
        f"[ -f {log} ] || {{ echo 'sm-remote: log not found:' {log} >&2; exit {EXIT_NOT_FOUND}; }}"
    )
    if follow:
        tail = f'tail -n {int(lines)} -F {log} & t=$!; cat >/dev/null; kill "$t" 2>/dev/null'
    else:
        tail = f"tail -n {int(lines)} {log}"
    return "; ".join([_cd_command(remote_dir), check, tail])


def build_attach_command(name: str) -> str:
    """ジョブの tmux セッションに接続するコマンドを返す（ssh -t で使う）．

    Args:
        name: ジョブ名．

    Returns:
        リモートで実行するシェルコマンド．
    """
    return f"tmux attach-session -t {_tmux_target(validate_job_name(name))}"


def build_attach_check_command(remote_dir: str, name: str) -> str:
    """attach の前に，ジョブの tmux セッションがまだあるかを確かめるコマンドを返す．

    セッションがあれば 0 で終わる．無ければログの最終行（`=== [...] exit N` 等）を
    標準出力に出して `EXIT_NO_SESSION`，ログも無ければ `EXIT_NOT_FOUND` で終わる．
    `ssh -t` の対話セッションは出力を受け取れないため，事前チェックは別の ssh で行う．

    Args:
        remote_dir: リモートのプロジェクトルート．
        name: ジョブ名．

    Returns:
        リモートで実行するシェルコマンド．
    """
    name = validate_job_name(name)
    log = shlex.quote(_job_log_path(name))
    statements = [
        f"if tmux has-session -t {_tmux_target(name)} 2>/dev/null; then exit 0; fi",
        _cd_command(remote_dir),
        f"if [ -f {log} ]; then {_last_line_command(log)}; exit {EXIT_NO_SESSION}; fi",
        f"exit {EXIT_NOT_FOUND}",
    ]
    return "; ".join(statements)


def build_kill_command(remote_dir: str, name: str) -> str:
    """ジョブの tmux セッションを終了し，ログにその旨を追記するコマンドを返す．

    tmux がセッションを閉じるとジョブの bash は SIGHUP で終わり，自分では終了行を
    書けないため，終了前にここで追記する．

    Args:
        remote_dir: リモートのプロジェクトルート．
        name: ジョブ名．

    Returns:
        リモートで実行するシェルコマンド．
    """
    name = validate_job_name(name)
    target = _tmux_target(name)
    log = shlex.quote(_job_log_path(name))
    statements = [
        f"tmux has-session -t {target} 2>/dev/null || "
        f"{{ echo 'sm-remote: no such tmux session:' {shlex.quote(name)} >&2; "
        f"exit {EXIT_NO_SESSION}; }}",
        f"if cd {quote_remote_path(remote_dir)} 2>/dev/null && [ -f {log} ]; then "
        f"printf '=== [%s] killed by sm-remote kill\\n' {_NOW} >> {log}; fi",
        f"tmux kill-session -t {target}",
    ]
    return "; ".join(statements)


def normalize_output_subpath(text: str) -> str:
    """pull 対象の指定を `output/<サブパス>` 形式に正規化する．

    `offaxis_led_sweep`・`output/offaxis_led_sweep`・`output\\offaxis_led_sweep`（Windows 区切り）
    をいずれも `output/offaxis_led_sweep` にする．output/ の外や output/ 全体は指定できない．

    Args:
        text: ユーザーが指定したパス．

    Returns:
        リモートのプロジェクトルート基準の相対パス（POSIX 区切り）．

    Raises:
        ValueError: 空・絶対パス・`..` を含む・output/ 全体を指す場合．
    """
    cleaned = text.strip().replace("\\", "/")
    if not cleaned:
        raise ValueError("取得するパスが空です．")
    if cleaned.startswith("/") or _WINDOWS_DRIVE_RE.match(cleaned):
        raise ValueError(f"絶対パスは指定できません: {text!r}（output/ 配下の相対パスを指定）")
    parts = [p for p in PurePosixPath(cleaned).parts if p not in ("", ".")]
    if ".." in parts:
        raise ValueError(f"'..' を含むパスは指定できません: {text!r}")
    if parts and parts[0] == REMOTE_OUTPUT_DIR:
        parts = parts[1:]
    if not parts:
        raise ValueError("output/ 全体は指定できません．サブディレクトリを指定してください．")
    return "/".join([REMOTE_OUTPUT_DIR, *parts])


def build_pull_command(remote_dir: str, rel_paths: Sequence[str], *, compress: bool) -> str:
    """指定パスを tar ストリームとして標準出力へ書き出すコマンドを返す．

    どれか 1 つでも存在しなければ tar を始める前に `EXIT_NOT_FOUND` で終わる．

    Args:
        remote_dir: リモートのプロジェクトルート．
        rel_paths: `normalize_output_subpath` で正規化済みのパス．
        compress: True なら gzip 圧縮する．

    Returns:
        リモートで実行するシェルコマンド．
    """
    if not rel_paths:
        raise ValueError("取得するパスが指定されていません．")
    quoted = " ".join(shlex.quote(p) for p in rel_paths)
    flags = "-czf" if compress else "-cf"
    statements = [
        _cd_command(remote_dir),
        f'for p in {quoted}; do [ -e "$p" ] || '
        f"{{ echo 'sm-remote: not found on remote:' \"$p\" >&2; exit {EXIT_NOT_FOUND}; }}; done",
        f"tar {flags} - {quoted}",
    ]
    return "; ".join(statements)
