"""remote/commands.py の仕様ベーステスト．

仕様出典:
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
- docs/02_ENV/ENV_02_環境構築手順.md「研究室サーバ編」
- 実装サマリー（sm-remote 仕様 1〜5）

テスト方針:
- ssh・tmux・実サーバには依存しない（コマンド文字列の組み立てだけを検証する）
- 「リモートの POSIX シェルがどう単語分割するか」を shlex（posix モード）で再現し，
  ユーザー由来の値（パス・コマンド引数・環境変数値）が 1 語のまま届くことを確かめる
"""

from __future__ import annotations

import shlex

import pytest

from sm_tias_lightsim.remote import commands as cmd

REMOTE_DIR = "~/lab/SmTIAS-LightSim"
REMOTE_DIR_EXPANDED = "$HOME/lab/SmTIAS-LightSim"
UV = "~/.local/bin/uv"


# ---------------------------------------------------------------------------
# ヘルパ（シェルの単語分割の再現）
# ---------------------------------------------------------------------------


def _shell_words(text: str) -> list[str]:
    """POSIX シェル相当の単語分割（; & | は区切り記号として別トークンにする）．"""
    lex = shlex.shlex(text, posix=True, punctuation_chars=";&|")
    lex.whitespace_split = True
    return list(lex)


def _statements(text: str) -> list[list[str]]:
    """`;` で区切った文ごとの単語リストを返す（引用符内の ; は区切らない）．"""
    result: list[list[str]] = [[]]
    for word in _shell_words(text):
        if word == ";":
            result.append([])
        else:
            result[-1].append(word)
    return [s for s in result if s]


def _has_sequence(words: list[str], seq: list[str]) -> bool:
    """words に seq が連続して現れるか．"""
    n = len(seq)
    return any(words[i : i + n] == seq for i in range(len(words) - n + 1))


def _index_of_sequence(words: list[str], seq: list[str]) -> int:
    n = len(seq)
    for i in range(len(words) - n + 1):
        if words[i : i + n] == seq:
            return i
    raise AssertionError(f"{seq} が見つかりません: {words}")


def _job_script(**overrides) -> str:
    kwargs = {
        "remote_dir": REMOTE_DIR,
        "name": "offaxis",
        "command": ["python", "scripts/sweep_offaxis_led.py"],
        "uv_path": UV,
        "use_uv": True,
        "env": {"PYTHONUNBUFFERED": "1", "SM_RENDER_VARIANT": "cuda_ad_rgb"},
    }
    kwargs.update(overrides)
    return cmd.build_job_script(
        kwargs["remote_dir"],
        kwargs["name"],
        kwargs["command"],
        uv_path=kwargs["uv_path"],
        use_uv=kwargs["use_uv"],
        env=kwargs["env"],
    )


def _run_statement(script: str) -> list[str]:
    """ジョブ本体（標準入力を /dev/null にしてコマンドを実行する文）を返す．"""
    candidates = [s for s in _statements(script) if "/dev/null" in s and "tee" in s]
    assert len(candidates) == 1, f"ジョブ本体の文が 1 つに特定できません: {candidates}"
    return candidates[0]


# ---------------------------------------------------------------------------
# 終了コード（仕様 2・3）
# ---------------------------------------------------------------------------


def test_exit_codes_match_spec():
    """リモートスクリプトの終了コードが仕様の値であること（90/91/92/93）．"""
    assert cmd.EXIT_REMOTE_DIR_MISSING == 90
    assert cmd.EXIT_SESSION_EXISTS == 91
    assert cmd.EXIT_NOT_FOUND == 92
    assert cmd.EXIT_NO_SESSION == 93


def test_log_dir_is_under_output():
    """ジョブログの置き場所が output/_remote_logs であること（仕様 2）．"""
    assert cmd.REMOTE_LOG_DIR == "output/_remote_logs"


# ---------------------------------------------------------------------------
# quote_remote_path（仕様 5: ~ はリモートで展開させる）
# ---------------------------------------------------------------------------


class TestQuoteRemotePath:
    def test_tilde_path_expands_home_on_remote(self):
        """`~/...` は `"$HOME"/...` になり，リモートのシェルで $HOME が展開される形であること．"""
        quoted = cmd.quote_remote_path(REMOTE_DIR)
        assert quoted.startswith('"$HOME"/')
        assert shlex.split(quoted) == [REMOTE_DIR_EXPANDED]

    @pytest.mark.parametrize("path", ["~", "~/"])
    def test_bare_tilde_is_home(self, path):
        assert cmd.quote_remote_path(path) == '"$HOME"'

    def test_tilde_remainder_is_quoted_against_expansion(self):
        """`~/` 以降はシェルに解釈させない（$(...) 等を含んでもそのまま 1 語）．"""
        quoted = cmd.quote_remote_path("~/$(id)")
        assert quoted == '"$HOME"/' + shlex.quote("$(id)")

    @pytest.mark.parametrize(
        ("path", "expected"),
        [
            ("~/my dir/it's", "$HOME/my dir/it's"),
            ("~/x; rm -rf /", "$HOME/x; rm -rf /"),
            ("/srv/data dir/proj", "/srv/data dir/proj"),
            ("lab/proj", "lab/proj"),
        ],
    )
    def test_special_characters_stay_single_word(self, path, expected):
        """空白・引用符・; を含むパスもシェルで 1 語として扱われること．"""
        assert shlex.split(cmd.quote_remote_path(path)) == [expected]

    @pytest.mark.parametrize("path", ["", "   "])
    def test_empty_path_raises(self, path):
        with pytest.raises(ValueError):
            cmd.quote_remote_path(path)

    def test_tilde_user_form_is_rejected(self):
        """`~ユーザー名/...` 形式は対応しない（ValueError）．"""
        with pytest.raises(ValueError):
            cmd.quote_remote_path("~other/lab")


# ---------------------------------------------------------------------------
# 入力の検証（仕様 2: ジョブ名と GPU 指定を検証する）
# ---------------------------------------------------------------------------


class TestValidateHost:
    @pytest.mark.parametrize("host", ["Newton", "Newton.local", "192.168.23.13", "user@host"])
    def test_valid_hosts_pass_through(self, host):
        assert cmd.validate_host(host) == host

    @pytest.mark.parametrize(
        "host", ["", "-oProxyCommand=calc", "-t", "New ton", "Newton\n", "\tNewton"]
    )
    def test_invalid_hosts_raise(self, host):
        """空・`-` 始まり（ssh のオプション注入）・空白入りは拒否すること．"""
        with pytest.raises(ValueError):
            cmd.validate_host(host)


class TestValidateJobName:
    @pytest.mark.parametrize(
        "name", ["offaxis", "r100", "pytest-all", "job-20260913-120000", "A_b-9", "x" * 64]
    )
    def test_valid_names(self, name):
        assert cmd.validate_job_name(name) == name

    @pytest.mark.parametrize(
        "name",
        [
            "",
            "-job",  # オプションと紛らわしい
            "_job",
            "a.b",  # tmux のセッション名に使えない
            "a:b",
            "a b",
            "a;b",
            "a/b",
            "../x",
            "$(id)",
            "ジョブ",
            "x" * 65,  # 64 文字超
        ],
    )
    def test_invalid_names_raise(self, name):
        with pytest.raises(ValueError):
            cmd.validate_job_name(name)


class TestValidateGpuSpec:
    @pytest.mark.parametrize("spec", ["0", "1", "0,2", "0,1,2", "10"])
    def test_valid_specs(self, spec):
        assert cmd.validate_gpu_spec(spec) == spec

    @pytest.mark.parametrize("spec", ["", "a", "0,", ",0", "0, 1", "-1", "0;rm -rf ~", "0 1"])
    def test_invalid_specs_raise(self, spec):
        with pytest.raises(ValueError):
            cmd.validate_gpu_spec(spec)


class TestParseEnvAssignment:
    @pytest.mark.parametrize(
        ("text", "expected"),
        [
            ("KEY=VALUE", ("KEY", "VALUE")),
            ("KEY=", ("KEY", "")),
            ("KEY=a=b", ("KEY", "a=b")),
            ("_K1=with space", ("_K1", "with space")),
        ],
    )
    def test_valid_assignments(self, text, expected):
        assert cmd.parse_env_assignment(text) == expected

    @pytest.mark.parametrize("text", ["KEY", "=v", "1KEY=v", "K-1=v", "K 1=v", ""])
    def test_invalid_assignments_raise(self, text):
        with pytest.raises(ValueError):
            cmd.parse_env_assignment(text)


# ---------------------------------------------------------------------------
# build_ssh_argv
# ---------------------------------------------------------------------------


class TestBuildSshArgv:
    def test_remote_command_is_single_argument(self):
        argv = cmd.build_ssh_argv("ssh", "Newton", "echo a b; ls")
        assert argv == ["ssh", "Newton", "echo a b; ls"]

    def test_tty_adds_t_option_before_host(self):
        argv = cmd.build_ssh_argv("ssh", "Newton", "tmux attach", tty=True)
        assert argv == ["ssh", "-t", "Newton", "tmux attach"]

    def test_invalid_host_raises(self):
        with pytest.raises(ValueError):
            cmd.build_ssh_argv("ssh", "-oProxyCommand=calc", "echo")


# ---------------------------------------------------------------------------
# push（仕様 1）
# ---------------------------------------------------------------------------


class TestBuildPushCommand:
    def test_extracts_stdin_tar_into_remote_dir(self):
        """標準入力の tar をリモートのプロジェクトルートへ展開すること．"""
        statements = _statements(cmd.build_push_command(REMOTE_DIR, compress=False))
        last = statements[-1]
        assert _has_sequence(last, ["mkdir", "-p", REMOTE_DIR_EXPANDED])
        assert _has_sequence(last, ["tar", "-xf", "-", "-C", REMOTE_DIR_EXPANDED])

    def test_compress_uses_gzip_extraction(self):
        words = _shell_words(cmd.build_push_command(REMOTE_DIR, compress=True))
        assert _has_sequence(words, ["tar", "-xzf", "-", "-C", REMOTE_DIR_EXPANDED])

    def test_tar_is_the_last_command(self):
        """ssh の終了コード＝tar の終了コードになるよう tar が最後に実行されること．"""
        last = _statements(cmd.build_push_command(REMOTE_DIR, compress=False))[-1]
        assert last[last.index("tar") :] == ["tar", "-xf", "-", "-C", REMOTE_DIR_EXPANDED]

    def test_warns_when_tmux_sessions_are_running(self):
        """ジョブ実行中の push に警告を出すため tmux ls を確認すること（ENV_04 注意）．"""
        words = _shell_words(cmd.build_push_command(REMOTE_DIR, compress=False))
        assert _has_sequence(words, ["tmux", "ls"])

    def test_remote_dir_with_space_is_single_word(self):
        words = _shell_words(cmd.build_push_command("~/my lab/proj", compress=False))
        assert _has_sequence(words, ["-C", "$HOME/my lab/proj"])


class TestBuildListAndRemoveCommands:
    def test_list_targets_only_given_dirs(self):
        """--prune の一覧は指定ディレクトリ（src/tests/scripts）だけを対象にすること．"""
        text = cmd.build_list_files_command(
            REMOTE_DIR, ["src", "tests", "scripts"], ["__pycache__", ".venv"]
        )
        words = _shell_words(text)
        assert _has_sequence(words, ["in", "src", "tests", "scripts"])
        assert "output" not in words
        assert "configs" not in words
        assert "-print0" in words

    def test_list_skips_cache_dirs(self):
        words = _shell_words(
            cmd.build_list_files_command(REMOTE_DIR, ["src"], ["__pycache__", ".pytest_cache"])
        )
        assert _has_sequence(words, ["-name", "__pycache__"])
        assert _has_sequence(words, ["-name", ".pytest_cache"])
        assert "-prune" in words

    def test_list_exits_90_when_remote_dir_missing(self):
        words = _shell_words(cmd.build_list_files_command(REMOTE_DIR, ["src"], ["__pycache__"]))
        assert words[:2] == ["cd", REMOTE_DIR_EXPANDED]
        assert _has_sequence(words, ["exit", "90"])

    def test_remove_reads_nul_separated_paths(self):
        words = _shell_words(cmd.build_remove_files_command(REMOTE_DIR))
        assert words[:2] == ["cd", REMOTE_DIR_EXPANDED]
        assert _has_sequence(words, ["exit", "90"])
        assert _has_sequence(words, ["xargs", "-0", "rm", "-f", "--"])


# ---------------------------------------------------------------------------
# run（仕様 2）
# ---------------------------------------------------------------------------


class TestBuildJobScript:
    def test_runs_in_remote_project_root(self):
        first = _statements(_job_script())[0]
        assert first[:2] == ["cd", REMOTE_DIR_EXPANDED]
        assert _has_sequence(_shell_words(_job_script()), ["exit", "90"])

    def test_log_file_is_named_after_job(self):
        """ログは output/_remote_logs/<NAME>.log であること．"""
        statements = _statements(_job_script(name="r100"))
        assert ["log=output/_remote_logs/r100.log"] in statements

    def test_writes_start_and_exit_lines_to_log(self):
        """開始行と `exit <終了コード>` 行をログに追記すること．"""
        statements = _statements(_job_script())
        start = [s for s in statements if s[0] == "printf" and "start" in s[1]]
        exit_line = [s for s in statements if s[0] == "printf" and "exit" in s[1]]
        assert len(start) == 1
        assert len(exit_line) == 1
        assert start[0][-2:] == [">>", "$log"]
        assert exit_line[0][-2:] == [">>", "$log"]
        assert "$rc" in exit_line[0]
        # 開始行 → ジョブ本体 → 終了コード行 の順
        run_idx = statements.index(_run_statement(_job_script()))
        assert statements.index(start[0]) < run_idx < statements.index(exit_line[0])

    def test_start_line_shows_the_command(self):
        statements = _statements(_job_script(command=["sm-render", "--scene", "a.yaml"]))
        start = next(s for s in statements if s[0] == "printf" and "start" in s[1])
        assert "sm-render --scene a.yaml" in start[-3]

    def test_exit_code_of_command_is_propagated(self):
        """パイプ（tee）越しでもコマンド自身の終了コードで終わること．"""
        statements = _statements(_job_script())
        assert ["rc=${PIPESTATUS[0]}"] in statements
        assert statements[-1] == ["exit", "$rc"]

    def test_output_is_also_tee_to_log(self):
        run = _run_statement(_job_script())
        assert _has_sequence(run, ["tee", "-a", "$log"])

    def test_stdin_is_dev_null(self):
        """入力待ちで止まらないよう標準入力を /dev/null にすること．"""
        run = _run_statement(_job_script())
        assert _has_sequence(run, ["<", "/dev/null"])

    def test_runs_command_with_uv_run(self):
        run = _run_statement(_job_script(command=["python", "scripts/sweep_offaxis_led.py"]))
        assert run[:4] == ["$HOME/.local/bin/uv", "run", "python", "scripts/sweep_offaxis_led.py"]

    def test_no_uv_runs_command_directly(self):
        run = _run_statement(_job_script(use_uv=False, command=["nvidia-smi"]))
        assert run[0] == "nvidia-smi"
        assert "run" not in run[:2]

    def test_arguments_are_quoted_and_survive_intact(self):
        """引数は shlex で正しくクォートされ，リモートのシェルでそのまま 1 語ずつ届くこと．"""
        command = [
            "python",
            "-c",
            "import sys; print('a b', \"$HOME\")",
            "--label=two words",
            "it's",
            "$(rm -rf ~)",
            "`id`",
            "a|b&c",
            "",
            "日本語 引数",
        ]
        run = _run_statement(_job_script(command=command))
        assert run[: 2 + len(command)] == ["$HOME/.local/bin/uv", "run", *command]

    def test_uv_directory_is_added_to_path(self):
        """スクリプトが `uv run sm-render` を名前で呼べるよう uv のディレクトリを PATH に足す．"""
        statements = _statements(_job_script())
        path_exports = [s for s in statements if s[0] == "export" and s[1].startswith("PATH=")]
        assert len(path_exports) == 1
        value = path_exports[0][1]
        assert "dirname" in value
        assert ".local/bin/uv" in value
        assert value.endswith(":$PATH")

    def test_custom_uv_path_is_used(self):
        run = _run_statement(_job_script(uv_path="/opt/uv tools/uv"))
        assert run[:2] == ["/opt/uv tools/uv", "run"]

    def test_env_values_are_exported_and_quoted(self):
        env = {
            "PYTHONUNBUFFERED": "1",
            "SM_RENDER_VARIANT": "cuda_ad_rgb",
            "CUDA_VISIBLE_DEVICES": "0,2",
            "WEIRD": "a b $(id); x",
        }
        statements = _statements(_job_script(env=env))
        for key, value in env.items():
            assert ["export", f"{key}={value}"] in statements

    def test_env_is_exported_before_the_command(self):
        statements = _statements(_job_script(env={"SM_DRJIT_THREADS": "16"}))
        export_idx = statements.index(["export", "SM_DRJIT_THREADS=16"])
        assert export_idx < statements.index(_run_statement(_job_script(env={"X": "1"})))

    def test_empty_command_raises(self):
        with pytest.raises(ValueError):
            _job_script(command=[])

    @pytest.mark.parametrize("name", ["a b", "a.b", "-x", ""])
    def test_invalid_job_name_raises(self, name):
        with pytest.raises(ValueError):
            _job_script(name=name)

    @pytest.mark.parametrize("key", ["1X", "A-B", "A B", "X;rm", ""])
    def test_invalid_env_key_raises(self, key):
        with pytest.raises(ValueError):
            _job_script(env={key: "1"})


def _tmux_new_session(run_command: str) -> list[str]:
    words = _shell_words(run_command)
    start = words.index("new-session")
    stmt = words[start:]
    return stmt[: stmt.index(";")] if ";" in stmt else stmt


class TestBuildRunCommand:
    def test_job_script_survives_nested_quoting(self):
        """tmux → bash -c と 2 段に渡してもジョブ本体が 1 文字も崩れないこと．"""
        script = _job_script(command=["python", "-c", "print('x; y')", "$(id)"])
        stmt = _tmux_new_session(cmd.build_run_command(REMOTE_DIR, "offaxis", script))
        assert shlex.split(stmt[-1]) == ["bash", "-c", script]

    def test_starts_detached_session_named_after_job(self):
        script = _job_script()
        stmt = _tmux_new_session(cmd.build_run_command(REMOTE_DIR, "offaxis", script))
        assert stmt[:4] == ["new-session", "-d", "-s", "offaxis"]
        assert _has_sequence(stmt, ["-c", "$PWD"])

    def test_existing_session_exits_91(self):
        """同名セッションが実行中なら起動せず 91 で終わること（tmux の完全一致指定 =NAME）．"""
        words = _shell_words(cmd.build_run_command(REMOTE_DIR, "off", _job_script(name="off")))
        check = _index_of_sequence(words, ["has-session", "-t", "=off"])
        exit91 = _index_of_sequence(words, ["exit", "91"])
        new = words.index("new-session")
        assert check < exit91 < new

    def test_creates_log_dir_and_enters_remote_dir(self):
        statements = _statements(cmd.build_run_command(REMOTE_DIR, "offaxis", _job_script()))
        assert statements[0][:2] == ["cd", REMOTE_DIR_EXPANDED]
        assert any(_has_sequence(s, ["mkdir", "-p", "output/_remote_logs"]) for s in statements)

    @pytest.mark.parametrize("name", ["a b", "a:b", "-t"])
    def test_invalid_name_raises(self, name):
        with pytest.raises(ValueError):
            cmd.build_run_command(REMOTE_DIR, name, "echo")


# ---------------------------------------------------------------------------
# status / logs / attach / kill（仕様 3）
# ---------------------------------------------------------------------------


class TestBuildStatusCommand:
    def test_shows_sessions_logs_gpu_and_load(self):
        words = _shell_words(cmd.build_status_command(REMOTE_DIR))
        assert words[:2] == ["cd", REMOTE_DIR_EXPANDED]
        assert _has_sequence(words, ["exit", "90"])
        assert _has_sequence(words, ["tmux", "ls"])
        assert any("output/_remote_logs/*.log" in w for w in words)
        assert "nvidia-smi" in words
        assert "uptime" in words


class TestBuildLogsCommand:
    def test_tail_last_n_lines(self):
        statements = _statements(cmd.build_logs_command(REMOTE_DIR, "job1", lines=7, follow=False))
        assert statements[-1] == ["tail", "-n", "7", "output/_remote_logs/job1.log"]

    def test_missing_log_exits_92(self):
        words = _shell_words(cmd.build_logs_command(REMOTE_DIR, "job1", lines=40, follow=False))
        assert _has_sequence(words, ["exit", "92"])
        assert _has_sequence(words, ["exit", "90"])

    def test_follow_stops_tail_when_stdin_closes(self):
        """-f は tail -F で追従し，手元が切れて標準入力が閉じたら tail を止めること．"""
        words = _shell_words(cmd.build_logs_command(REMOTE_DIR, "job1", lines=5, follow=True))
        assert _has_sequence(words, ["tail", "-n", "5", "-F", "output/_remote_logs/job1.log"])
        cat = _index_of_sequence(words, ["cat", ">/dev/null"])
        kill = words.index("kill")
        assert cat < kill

    def test_invalid_name_raises(self):
        with pytest.raises(ValueError):
            cmd.build_logs_command(REMOTE_DIR, "../etc/passwd", lines=1, follow=False)


class TestAttachCommands:
    def test_attach_uses_exact_session_match(self):
        assert shlex.split(cmd.build_attach_command("offaxis")) == [
            "tmux",
            "attach-session",
            "-t",
            "=offaxis",
        ]

    def test_attach_invalid_name_raises(self):
        with pytest.raises(ValueError):
            cmd.build_attach_command("a;b")

    def test_check_exit_codes_in_order(self):
        """セッションあり→0，無くてログあり→93，ログも無し→92，ディレクトリ無し→90．"""
        words = _shell_words(cmd.build_attach_check_command(REMOTE_DIR, "offaxis"))
        has = _index_of_sequence(words, ["has-session", "-t", "=offaxis"])
        exit0 = _index_of_sequence(words, ["exit", "0"])
        cd = _index_of_sequence(words, ["cd", REMOTE_DIR_EXPANDED])
        exit90 = _index_of_sequence(words, ["exit", "90"])
        log_check = _index_of_sequence(words, ["-f", "output/_remote_logs/offaxis.log"])
        exit93 = _index_of_sequence(words, ["exit", "93"])
        exit92 = _index_of_sequence(words, ["exit", "92"])
        assert has < exit0 < cd < exit90 < log_check < exit93 < exit92

    def test_check_prints_last_log_line_when_ended(self):
        words = _shell_words(cmd.build_attach_check_command(REMOTE_DIR, "offaxis"))
        assert _has_sequence(words, ["tail", "-n", "1", "output/_remote_logs/offaxis.log"])


class TestBuildKillCommand:
    def test_missing_session_exits_93(self):
        words = _shell_words(cmd.build_kill_command(REMOTE_DIR, "offaxis"))
        has = _index_of_sequence(words, ["has-session", "-t", "=offaxis"])
        assert has < _index_of_sequence(words, ["exit", "93"])

    def test_appends_note_to_log_then_kills_session(self):
        words = _shell_words(cmd.build_kill_command(REMOTE_DIR, "offaxis"))
        append = _index_of_sequence(words, [">>", "output/_remote_logs/offaxis.log"])
        kill = _index_of_sequence(words, ["kill-session", "-t", "=offaxis"])
        assert append < kill
        assert any("killed" in w for w in words)

    def test_invalid_name_raises(self):
        with pytest.raises(ValueError):
            cmd.build_kill_command(REMOTE_DIR, "a b")


# ---------------------------------------------------------------------------
# pull（仕様 4）
# ---------------------------------------------------------------------------


class TestNormalizeOutputSubpath:
    @pytest.mark.parametrize(
        "text",
        [
            "offaxis_led_sweep",
            "output/offaxis_led_sweep",
            "output\\offaxis_led_sweep",
            "./offaxis_led_sweep",
            "offaxis_led_sweep/",
            "  offaxis_led_sweep  ",
            "output//offaxis_led_sweep",
        ],
    )
    def test_accepts_all_spellings(self, text):
        """`foo` / `output/foo` / `output\\foo` のどれでも output/foo になること．"""
        assert cmd.normalize_output_subpath(text) == "output/offaxis_led_sweep"

    def test_nested_path(self):
        assert cmd.normalize_output_subpath("sweep\\r100\\figures") == "output/sweep/r100/figures"
        assert cmd.normalize_output_subpath("_remote_logs") == "output/_remote_logs"

    def test_similar_prefix_is_not_stripped(self):
        """`outputs/...` は output/ と見なさず，output/ 配下のサブパスとして扱う．"""
        assert cmd.normalize_output_subpath("outputs/x") == "output/outputs/x"

    @pytest.mark.parametrize(
        "text",
        [
            "",
            "   ",
            "/etc/passwd",
            "/output/x",
            "C:\\Users\\x",
            "c:/x",
            "C:x",
            "../x",
            "x/../../y",
            "output/../src",
            "..\\src",
            "output",
            "output/",
            "output\\",
            ".",
            "./",
        ],
    )
    def test_rejects_outside_output_and_whole_output(self, text):
        """output/ の外・絶対パス・`..`・output/ 全体は拒否すること．"""
        with pytest.raises(ValueError):
            cmd.normalize_output_subpath(text)


class TestBuildPullCommand:
    def test_tars_requested_paths_to_stdout(self):
        statements = _statements(
            cmd.build_pull_command(REMOTE_DIR, ["output/a", "output/b c"], compress=False)
        )
        assert statements[0][:2] == ["cd", REMOTE_DIR_EXPANDED]
        assert statements[-1] == ["tar", "-cf", "-", "output/a", "output/b c"]

    def test_compress_uses_gzip(self):
        last = _statements(cmd.build_pull_command(REMOTE_DIR, ["output/a"], compress=True))[-1]
        assert last == ["tar", "-czf", "-", "output/a"]

    def test_missing_path_exits_92_before_tar(self):
        words = _shell_words(cmd.build_pull_command(REMOTE_DIR, ["output/a"], compress=False))
        assert _index_of_sequence(words, ["exit", "92"]) < words.index("tar")
        assert _has_sequence(words, ["exit", "90"])

    def test_empty_paths_raise(self):
        with pytest.raises(ValueError):
            cmd.build_pull_command(REMOTE_DIR, [], compress=False)
