"""Tests for crank_cli.main — run with: python -m pytest tests/"""

import json
import os
import sys
import tempfile
import unittest
from argparse import Namespace
from io import StringIO
from unittest.mock import MagicMock, call, mock_open, patch

from crank_cli.main import (
    DEFAULT_CRANK_HOME,
    RESULTS,
    _format_ts,
    _get_config,
    _result_str,
    _run_cmd,
    _run_python,
    _shell_quote,
    _validate_job_name,
    cmd_abort,
    cmd_cat,
    cmd_config,
    cmd_create,
    cmd_delete,
    cmd_diff,
    cmd_disable,
    cmd_edit,
    cmd_enable,
    cmd_export,
    cmd_history,
    cmd_init,
    cmd_list,
    cmd_logs,
    cmd_retry,
    cmd_schedule,
    cmd_stats,
    cmd_status,
    cmd_tail,
    cmd_trigger,
    cmd_wait,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_args(**kwargs):
    """Return an argparse-like Namespace with sensible defaults."""
    defaults = {
        "remote": None,
        "crank_home": None,
        "json": False,
    }
    defaults.update(kwargs)
    return Namespace(**defaults)


def _make_cfg(host=None, home=DEFAULT_CRANK_HOME):
    return {
        "host": host,
        "home": home,
        "jobs_dir": f"{home}/cfg/jobs",
        "db": f"{home}/crank.sqlite",
    }


# ===================================================================
# _validate_job_name
# ===================================================================

class TestValidateJobName(unittest.TestCase):
    def test_simple_alpha(self):
        _validate_job_name("build")

    def test_alphanumeric(self):
        _validate_job_name("build123")

    def test_dashes_underscores(self):
        _validate_job_name("my-job_v2")

    def test_single_char(self):
        _validate_job_name("a")

    def test_all_digits(self):
        _validate_job_name("12345")

    def test_uppercase(self):
        _validate_job_name("MyBuild")

    def _assert_rejects(self, name):
        with self.assertRaises(SystemExit) as ctx:
            _validate_job_name(name)
        self.assertEqual(ctx.exception.code, 1)

    def test_semicolon_injection(self):
        self._assert_rejects("build; rm -rf /")

    def test_backtick_injection(self):
        self._assert_rejects("`whoami`")

    def test_dollar_paren_injection(self):
        self._assert_rejects("$(id)")

    def test_pipe_injection(self):
        self._assert_rejects("job|cat /etc/passwd")

    def test_space(self):
        self._assert_rejects("my job")

    def test_empty_string(self):
        self._assert_rejects("")

    def test_slash(self):
        self._assert_rejects("../../../etc/passwd")

    def test_ampersand(self):
        self._assert_rejects("job&whoami")

    def test_newline(self):
        self._assert_rejects("job\nwhoami")


# ===================================================================
# _shell_quote
# ===================================================================

class TestShellQuote(unittest.TestCase):
    def test_basic_string(self):
        self.assertEqual(_shell_quote("hello"), "'hello'")

    def test_string_with_spaces(self):
        self.assertEqual(_shell_quote("hello world"), "'hello world'")

    def test_string_with_single_quote(self):
        quoted = _shell_quote("it's")
        self.assertEqual(quoted, "'it'\"'\"'s'")

    def test_empty_string(self):
        self.assertEqual(_shell_quote(""), "''")

    def test_special_chars(self):
        for ch in [";", "|", "&", "$", "`", "(", ")", "\\", "\n", '"']:
            quoted = _shell_quote(f"a{ch}b")
            self.assertTrue(quoted.startswith("'"), f"failed for {ch!r}")

    def test_multiple_single_quotes(self):
        quoted = _shell_quote("a'b'c")
        self.assertEqual(quoted.count("'\"'\"'"), 2)


# ===================================================================
# _format_ts
# ===================================================================

class TestFormatTs(unittest.TestCase):
    def test_known_timestamp(self):
        self.assertEqual(_format_ts(1705321800), "2024-01-15 12:30:00 UTC")

    def test_epoch_zero(self):
        self.assertEqual(_format_ts(0), "-")

    def test_none(self):
        self.assertEqual(_format_ts(None), "-")

    def test_negative_treated_as_truthy(self):
        result = _format_ts(-1)
        self.assertNotEqual(result, "-")
        self.assertTrue(result.endswith("UTC"))

    def test_returns_string(self):
        self.assertIsInstance(_format_ts(1000000), str)

    def test_output_contains_utc(self):
        self.assertIn("UTC", _format_ts(1705321800))


# ===================================================================
# _result_str
# ===================================================================

class TestResultStr(unittest.TestCase):
    def test_all_known_codes(self):
        expected = {0: "unknown", 1: "queued", 2: "running",
                    3: "aborted", 4: "failed", 5: "success"}
        for code, label in expected.items():
            self.assertEqual(_result_str(code), label)

    def test_unknown_code(self):
        self.assertEqual(_result_str(99), "unknown(99)")

    def test_negative_code(self):
        self.assertEqual(_result_str(-1), "unknown(-1)")


# ===================================================================
# _get_config
# ===================================================================

class TestGetConfig(unittest.TestCase):
    def test_explicit_args(self):
        args = _make_args(remote="myhost", crank_home="/opt/crank")
        cfg = _get_config(args)
        self.assertEqual(cfg["host"], "myhost")
        self.assertEqual(cfg["home"], "/opt/crank")
        self.assertEqual(cfg["jobs_dir"], "/opt/crank/cfg/jobs")
        self.assertEqual(cfg["db"], "/opt/crank/crank.sqlite")

    @patch.dict("os.environ", {"CRANK_REMOTE": "envhost", "CRANK_HOME": "/env/crank"})
    def test_env_var_fallback(self):
        args = _make_args()
        cfg = _get_config(args)
        self.assertEqual(cfg["host"], "envhost")
        self.assertEqual(cfg["home"], "/env/crank")

    @patch.dict("os.environ", {}, clear=True)
    def test_defaults_when_nothing_set(self):
        args = _make_args()
        cfg = _get_config(args)
        self.assertIsNone(cfg["host"])
        self.assertEqual(cfg["home"], DEFAULT_CRANK_HOME)

    @patch.dict("os.environ", {"CRANK_REMOTE": "envhost"})
    def test_explicit_overrides_env(self):
        args = _make_args(remote="clihost")
        cfg = _get_config(args)
        self.assertEqual(cfg["host"], "clihost")


# ===================================================================
# _run_cmd
# ===================================================================

class TestRunCmd(unittest.TestCase):
    @patch("subprocess.run")
    def test_local_mode(self, mock_sp):
        mock_sp.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
        cfg = _make_cfg(host=None)
        result = _run_cmd(cfg, "echo hello")
        mock_sp.assert_called_once_with(
            ["sh", "-c", "echo hello"], capture_output=True, text=True)
        self.assertEqual(result.stdout, "ok")

    @patch("subprocess.run")
    def test_ssh_mode(self, mock_sp):
        mock_sp.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
        cfg = _make_cfg(host="myhost")
        _run_cmd(cfg, "echo hello")
        mock_sp.assert_called_once_with(
            ["ssh", "myhost", "echo hello"], capture_output=True, text=True)

    @patch("subprocess.run")
    def test_check_exits_on_failure(self, mock_sp):
        mock_sp.return_value = MagicMock(returncode=1, stdout="", stderr="boom")
        cfg = _make_cfg()
        with self.assertRaises(SystemExit):
            _run_cmd(cfg, "false", check=True)

    @patch("subprocess.run")
    def test_check_false_does_not_exit(self, mock_sp):
        mock_sp.return_value = MagicMock(returncode=1, stdout="", stderr="boom")
        cfg = _make_cfg()
        result = _run_cmd(cfg, "false", check=False)
        self.assertEqual(result.returncode, 1)


# ===================================================================
# _run_python error handling
# ===================================================================

class TestRunPythonErrorHandling(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    def test_empty_stdout_exits(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", stderr="", returncode=0)
        cfg = _make_cfg()
        with self.assertRaises(SystemExit) as ctx:
            _run_python(cfg, "print('x')")
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    def test_invalid_json_exits(self, mock_run):
        mock_run.return_value = MagicMock(stdout="not json", stderr="", returncode=0)
        cfg = _make_cfg()
        with self.assertRaises(SystemExit) as ctx:
            _run_python(cfg, "print('x')")
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    def test_valid_json_returns_parsed(self, mock_run):
        mock_run.return_value = MagicMock(stdout='[{"a": 1}]', stderr="", returncode=0)
        cfg = _make_cfg()
        result = _run_python(cfg, "x")
        self.assertEqual(result, [{"a": 1}])


# ===================================================================
# cmd_trigger
# ===================================================================

class TestCmdTrigger(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_run_mode(self, mock_run):
        mock_run.return_value = MagicMock(stdout="OK\n", returncode=0)
        args = _make_args(job="deploy", env=[], queue=False, command="trigger")
        cmd_trigger(args)
        called_cmd = mock_run.call_args[0][1]
        self.assertIn("crankc run", called_cmd)
        self.assertIn("'deploy'", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_queue_mode(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", env=[], queue=True, command="trigger")
        cmd_trigger(args)
        called_cmd = mock_run.call_args[0][1]
        self.assertIn("crankc queue", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_env_vars_shell_quoted(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", env=["FOO=hello world"], queue=False, command="trigger")
        cmd_trigger(args)
        called_cmd = mock_run.call_args[0][1]
        self.assertIn("'FOO=hello world'", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_nonzero_exit(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", returncode=2)
        args = _make_args(job="fail", env=[], queue=False, command="trigger")
        with self.assertRaises(SystemExit) as ctx:
            cmd_trigger(args)
        self.assertEqual(ctx.exception.code, 2)


# ===================================================================
# cmd_cat
# ===================================================================

class TestCmdCat(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_normal_output(self, mock_run):
        mock_run.return_value = MagicMock(stdout="#!/bin/bash\necho hello\n", returncode=0)
        args = _make_args(job="greet", command="cat")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_cat(args)
        self.assertIn("echo hello", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_job_not_found(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", returncode=1)
        args = _make_args(job="nope", command="cat")
        with self.assertRaises(SystemExit) as ctx:
            cmd_cat(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_logs
# ===================================================================

class TestCmdLogsInvalidRun(unittest.TestCase):
    @patch.dict("os.environ", {}, clear=True)
    def test_non_numeric_run_number(self):
        args = _make_args(job="build", run="abc", last=1, command="logs")
        with self.assertRaises(SystemExit) as ctx:
            cmd_logs(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_injection_via_run_number(self):
        args = _make_args(job="build", run="1 OR 1=1", last=1, command="logs")
        with self.assertRaises(SystemExit) as ctx:
            cmd_logs(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_valid_numeric_run_passes(self, mock_run_py):
        mock_run_py.return_value = []
        args = _make_args(job="build", run="42", last=1, command="logs")
        cmd_logs(args)

    @patch.dict("os.environ", {}, clear=True)
    def test_negative_last_rejected(self):
        args = _make_args(job="build", run=None, last=-1, command="logs")
        with self.assertRaises(SystemExit) as ctx:
            cmd_logs(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_zero_last_rejected(self):
        args = _make_args(job="build", run=None, last=0, command="logs")
        with self.assertRaises(SystemExit) as ctx:
            cmd_logs(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_status
# ===================================================================

class TestCmdStatus(unittest.TestCase):
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_shows_running_and_queued(self, mock_run_py):
        mock_run_py.return_value = [
            {"name": "deploy", "number": 5, "result": 2, "started": 1705321800,
             "queued": 1705321790, "reason": "manual", "duration": 120},
            {"name": "build", "number": 3, "result": 1, "started": None,
             "queued": 1705321850, "reason": "webhook", "duration": None},
        ]
        args = _make_args(command="status")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_status(args)
        output = fake_out.getvalue()
        self.assertIn("deploy", output)
        self.assertIn("running", output)
        self.assertIn("120s", output)
        self.assertIn("build", output)
        self.assertIn("queued", output)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_no_running_or_queued(self, mock_run_py):
        mock_run_py.return_value = []
        args = _make_args(command="status")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_status(args)
        self.assertIn("No running or queued jobs", fake_out.getvalue())

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_json_output(self, mock_run_py):
        mock_run_py.return_value = [
            {"name": "deploy", "number": 5, "result": 2, "started": 1705321800,
             "queued": None, "reason": None, "duration": 60},
        ]
        args = _make_args(command="status", json=True)
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_status(args)
        parsed = json.loads(fake_out.getvalue())
        self.assertEqual(len(parsed), 1)
        self.assertEqual(parsed[0]["name"], "deploy")


# ===================================================================
# cmd_stats
# ===================================================================

class TestCmdStats(unittest.TestCase):
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_shows_statistics(self, mock_run_py):
        mock_run_py.return_value = {
            "total": 20, "success": 15, "failed": 3, "aborted": 2,
            "success_rate": 75.0, "avg_duration": 45.3,
            "min_duration": 10.0, "max_duration": 120.5,
        }
        args = _make_args(job="deploy", last=50, command="stats")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_stats(args)
        output = fake_out.getvalue()
        self.assertIn("75.0%", output)
        self.assertIn("45.3s", output)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_no_completed_runs(self, mock_run_py):
        mock_run_py.return_value = {
            "total": 0, "success": 0, "failed": 0, "aborted": 0,
            "success_rate": 0, "avg_duration": 0,
            "min_duration": 0, "max_duration": 0,
        }
        args = _make_args(job="new-job", last=50, command="stats")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_stats(args)
        self.assertIn("No completed runs found", fake_out.getvalue())

    @patch.dict("os.environ", {}, clear=True)
    def test_rejects_invalid_job_name(self):
        args = _make_args(job="bad;name", last=50, command="stats")
        with self.assertRaises(SystemExit) as ctx:
            cmd_stats(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_json_output(self, mock_run_py):
        mock_run_py.return_value = {
            "total": 5, "success": 5, "failed": 0, "aborted": 0,
            "success_rate": 100.0, "avg_duration": 30.0,
            "min_duration": 20.0, "max_duration": 40.0,
        }
        args = _make_args(job="build", last=10, command="stats", json=True)
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_stats(args)
        parsed = json.loads(fake_out.getvalue())
        self.assertEqual(parsed["success_rate"], 100.0)

    @patch.dict("os.environ", {}, clear=True)
    def test_negative_last_rejected(self):
        args = _make_args(job="build", last=-1, command="stats")
        with self.assertRaises(SystemExit) as ctx:
            cmd_stats(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_zero_last_rejected(self):
        args = _make_args(job="build", last=0, command="stats")
        with self.assertRaises(SystemExit) as ctx:
            cmd_stats(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_history -- negative --last
# ===================================================================

class TestCmdHistoryNegativeLast(unittest.TestCase):
    @patch.dict("os.environ", {}, clear=True)
    def test_negative_last_rejected(self):
        args = _make_args(job="build", last=-1, command="history")
        with self.assertRaises(SystemExit) as ctx:
            cmd_history(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_zero_last_rejected(self):
        args = _make_args(job="build", last=0, command="history")
        with self.assertRaises(SystemExit) as ctx:
            cmd_history(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_wait
# ===================================================================

class TestCmdWait(unittest.TestCase):
    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_wait_success(self, mock_run_py, mock_time):
        mock_time.time.return_value = 1000.0
        mock_time.sleep = MagicMock()
        mock_run_py.return_value = {"number": 5, "result": 5, "started": 900, "completed": 1000}
        args = _make_args(job="build", run=None, command="wait")
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 0)

    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_wait_failure_exits_1(self, mock_run_py, mock_time):
        mock_time.time.return_value = 1000.0
        mock_time.sleep = MagicMock()
        mock_run_py.return_value = {"number": 5, "result": 4, "started": 900, "completed": 1000}
        args = _make_args(job="build", run=None, command="wait")
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_wait_polls_then_completes(self, mock_run_py, mock_time):
        mock_time.time.return_value = 1000.0
        mock_time.sleep = MagicMock()
        mock_run_py.side_effect = [
            {"number": 7, "result": 2, "started": 990, "completed": None},
            {"number": 7, "result": 5, "started": 990, "completed": 1005},
        ]
        args = _make_args(job="build", run=None, command="wait")
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 0)
        mock_time.sleep.assert_called_once_with(2)

    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_wait_no_build_found(self, mock_run_py, mock_time):
        mock_time.time.return_value = 1000.0
        mock_time.sleep = MagicMock()
        mock_run_py.return_value = None
        args = _make_args(job="nonexistent", run=None, command="wait")
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_wait_invalid_run_number(self):
        args = _make_args(job="build", run="abc", command="wait")
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_wait_rejects_invalid_job_name(self):
        args = _make_args(job="bad;name", run=None, command="wait")
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_wait_timeout_exits_2(self, mock_run_py, mock_time):
        mock_time.time.side_effect = [1000.0, 1060.0]
        mock_time.sleep = MagicMock()
        mock_run_py.return_value = {"number": 5, "result": 2, "started": 990, "completed": None}
        args = _make_args(job="build", run=None, command="wait", timeout=30)
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 2)

    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_wait_no_timeout_by_default(self, mock_run_py, mock_time):
        mock_time.time.return_value = 1000.0
        mock_time.sleep = MagicMock()
        # Build completes on first poll; no timeout arg set
        mock_run_py.return_value = {"number": 5, "result": 5, "started": 900, "completed": 1000}
        args = _make_args(job="build", run=None, command="wait", timeout=None)
        with self.assertRaises(SystemExit) as ctx:
            cmd_wait(args)
        self.assertEqual(ctx.exception.code, 0)


# ===================================================================
# cmd_abort
# ===================================================================

class TestCmdAbort(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_abort_auto_finds_running_build(self, mock_run_py, mock_run_cmd):
        mock_run_py.return_value = {"number": 42, "result": 2}
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="deploy", run=None, command="abort")
        cmd_abort(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc abort", called_cmd)
        self.assertIn("'deploy'", called_cmd)
        self.assertIn("'42'", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_abort_specific_run_number(self, mock_run_cmd):
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run="5", command="abort")
        cmd_abort(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc abort", called_cmd)
        self.assertIn("'5'", called_cmd)

    @patch.dict("os.environ", {}, clear=True)
    def test_abort_invalid_run_number(self):
        args = _make_args(job="build", run="abc", command="abort")
        with self.assertRaises(SystemExit) as ctx:
            cmd_abort(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_abort_no_running_build_exits(self, mock_run_py):
        mock_run_py.return_value = None
        args = _make_args(job="deploy", run=None, command="abort")
        with self.assertRaises(SystemExit) as ctx:
            cmd_abort(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_abort_rejects_invalid_job_name(self):
        args = _make_args(job="build; rm -rf /", run=None, command="abort")
        with self.assertRaises(SystemExit) as ctx:
            cmd_abort(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_abort_nonzero_exit_propagated(self, mock_run_cmd):
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=3)
        args = _make_args(job="build", run="1", command="abort")
        with self.assertRaises(SystemExit) as ctx:
            cmd_abort(args)
        self.assertEqual(ctx.exception.code, 3)


# ===================================================================
# cmd_retry
# ===================================================================

class TestCmdRetry(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_auto_finds_failed_build(self, mock_run_py, mock_run_cmd):
        mock_run_py.return_value = {"number": 10, "result": 4}
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="deploy", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)
        self.assertIn("'deploy'", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_specific_run_number(self, mock_run_py, mock_run_cmd):
        mock_run_py.return_value = {"number": 7, "result": 3}
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run="7", command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)

    @patch.dict("os.environ", {}, clear=True)
    def test_retry_invalid_run_number(self):
        args = _make_args(job="build", run="xyz", command="retry")
        with self.assertRaises(SystemExit) as ctx:
            cmd_retry(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_no_failed_build_exits(self, mock_run_py):
        mock_run_py.return_value = None
        args = _make_args(job="deploy", run=None, command="retry")
        with self.assertRaises(SystemExit) as ctx:
            cmd_retry(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_retry_rejects_invalid_job_name(self):
        args = _make_args(job="$(whoami)", run=None, command="retry")
        with self.assertRaises(SystemExit) as ctx:
            cmd_retry(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_tail
# ===================================================================

class TestCmdTail(unittest.TestCase):
    @patch("os.execvp")
    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_auto_finds_running_build_local(self, mock_run_py, mock_run_cmd, mock_exec):
        mock_run_py.return_value = {"number": 15}
        mock_run_cmd.return_value = MagicMock(returncode=0)  # test -f succeeds
        args = _make_args(job="deploy", run=None, command="tail")
        cmd_tail(args)
        call_args = mock_exec.call_args[0]
        self.assertEqual(call_args[0], "tail")
        self.assertIn("deploy/15/log", call_args[1][2])

    @patch("os.execvp")
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_specific_run_local(self, mock_run_cmd, mock_exec):
        mock_run_cmd.return_value = MagicMock(returncode=0)  # test -f succeeds
        args = _make_args(job="build", run="3", command="tail")
        cmd_tail(args)
        call_args = mock_exec.call_args[0]
        self.assertIn("build/3/log", call_args[1][2])

    @patch("os.execvp")
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_ssh_mode(self, mock_run_cmd, mock_exec):
        mock_run_cmd.return_value = MagicMock(returncode=0)  # test -f succeeds
        args = _make_args(job="deploy", run="5", remote="server1", command="tail")
        cmd_tail(args)
        call_args = mock_exec.call_args[0]
        self.assertEqual(call_args[0], "ssh")
        self.assertIn("tail -f", call_args[1][3])
        self.assertIn("'", call_args[1][3])

    @patch.dict("os.environ", {}, clear=True)
    def test_tail_invalid_run_number(self):
        args = _make_args(job="build", run="not-a-number", command="tail")
        with self.assertRaises(SystemExit) as ctx:
            cmd_tail(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_no_running_build_exits(self, mock_run_py):
        mock_run_py.return_value = None
        args = _make_args(job="deploy", run=None, command="tail")
        with self.assertRaises(SystemExit) as ctx:
            cmd_tail(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_tail_rejects_invalid_job_name(self):
        args = _make_args(job="job|cat", run=None, command="tail")
        with self.assertRaises(SystemExit) as ctx:
            cmd_tail(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("os.execvp")
    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_waits_for_log_file(self, mock_run_cmd, mock_run_py, mock_time, mock_exec):
        mock_time.sleep = MagicMock()
        mock_time.time = MagicMock(side_effect=[1000.0, 1001.0, 1002.0])
        # First call: test -f fails, second: test -f succeeds
        mock_run_cmd.side_effect = [
            MagicMock(returncode=1),  # test -f => not found
            MagicMock(returncode=0),  # test -f => found
        ]
        # Build status check: still running (result=2)
        mock_run_py.return_value = 2
        args = _make_args(job="build", run="7", command="tail")
        cmd_tail(args)
        mock_time.sleep.assert_called_once_with(1)
        mock_exec.assert_called_once()


# ===================================================================
# cmd_edit
# ===================================================================

class TestCmdEdit(unittest.TestCase):
    @patch("os.execvp")
    @patch.dict("os.environ", {}, clear=True)
    def test_edit_ssh_mode_quotes_path(self, mock_exec):
        args = _make_args(job="my-build", remote="server1", command="edit")
        cmd_edit(args)
        ssh_command = mock_exec.call_args[0][1][3]
        self.assertIn("'", ssh_command)
        self.assertIn("my-build.run", ssh_command)

    @patch.dict("os.environ", {}, clear=True)
    def test_edit_rejects_invalid_job(self):
        args = _make_args(job="test;whoami", command="edit")
        with self.assertRaises(SystemExit) as ctx:
            cmd_edit(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_create
# ===================================================================

class TestCmdCreate(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_create_default_template(self, mock_run):
        mock_run.side_effect = [
            MagicMock(returncode=1),   # test -f => not found
            MagicMock(returncode=0),   # tee
            MagicMock(returncode=0),   # chmod
        ]
        args = _make_args(job="deploy", template=None, edit=False, command="create")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_create(args)
        self.assertIn("Created:", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_create_from_template(self, mock_run):
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="#!/bin/bash\necho template\n"),
            MagicMock(returncode=1),   # test -f
            MagicMock(returncode=0),   # tee
            MagicMock(returncode=0),   # chmod
        ]
        args = _make_args(job="new-job", template="old-job", edit=False, command="create")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_create(args)
        self.assertIn("Created:", fake_out.getvalue())
        cat_cmd = mock_run.call_args_list[0][0][1]
        self.assertIn("old-job.run", cat_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_create_already_exists(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=0)]
        args = _make_args(job="deploy", template=None, edit=False, command="create")
        with self.assertRaises(SystemExit) as ctx:
            cmd_create(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_create_rejects_invalid_job_name(self):
        args = _make_args(job="bad;name", template=None, edit=False, command="create")
        with self.assertRaises(SystemExit) as ctx:
            cmd_create(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_create_rejects_invalid_template_name(self, mock_run):
        args = _make_args(job="good", template="bad;template", edit=False, command="create")
        with self.assertRaises(SystemExit) as ctx:
            cmd_create(args)
        self.assertEqual(ctx.exception.code, 1)
        mock_run.assert_not_called()

    @patch("os.execvp")
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_create_with_edit_flag(self, mock_run, mock_exec):
        mock_run.side_effect = [
            MagicMock(returncode=1),
            MagicMock(returncode=0),
            MagicMock(returncode=0),
        ]
        args = _make_args(job="myjob", template=None, edit=True, command="create")
        with patch("sys.stdout", new_callable=StringIO):
            cmd_create(args)
        mock_exec.assert_called_once()


# ===================================================================
# cmd_delete / cmd_disable / cmd_enable
# ===================================================================

class TestCmdDelete(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_delete_without_yes(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=0)]
        args = _make_args(job="deploy", yes=False, command="delete")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_delete(args)
        self.assertIn("Would delete:", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_delete_with_yes(self, mock_run):
        mock_run.side_effect = [
            MagicMock(returncode=0),
            MagicMock(returncode=0),
        ]
        args = _make_args(job="deploy", yes=True, command="delete")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_delete(args)
        self.assertIn("Deleted:", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_delete_job_not_found(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=1)]
        args = _make_args(job="nope", yes=True, command="delete")
        with self.assertRaises(SystemExit) as ctx:
            cmd_delete(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_delete_rejects_invalid_job_name(self):
        args = _make_args(job="bad;name", yes=True, command="delete")
        with self.assertRaises(SystemExit) as ctx:
            cmd_delete(args)
        self.assertEqual(ctx.exception.code, 1)


class TestCmdDisable(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_disable_success(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=0), MagicMock(returncode=0)]
        args = _make_args(job="deploy", command="disable")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_disable(args)
        self.assertIn("Disabled:", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_disable_job_not_found(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=1)]
        args = _make_args(job="nope", command="disable")
        with self.assertRaises(SystemExit) as ctx:
            cmd_disable(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_disable_rejects_invalid_job_name(self):
        args = _make_args(job="$(whoami)", command="disable")
        with self.assertRaises(SystemExit) as ctx:
            cmd_disable(args)
        self.assertEqual(ctx.exception.code, 1)


class TestCmdEnable(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_enable_success(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=0), MagicMock(returncode=0)]
        args = _make_args(job="deploy", command="enable")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_enable(args)
        self.assertIn("Enabled:", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_enable_not_found(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=1)]
        args = _make_args(job="nope", command="enable")
        with self.assertRaises(SystemExit) as ctx:
            cmd_enable(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_enable_rejects_invalid_job_name(self):
        args = _make_args(job="../passwd", command="enable")
        with self.assertRaises(SystemExit) as ctx:
            cmd_enable(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_config
# ===================================================================

class TestCmdConfig(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_config_cat_default_conf(self, mock_run):
        mock_run.return_value = MagicMock(stdout="DESCRIPTION=My job\n", returncode=0)
        args = _make_args(job="deploy", type="conf", edit=False, command="config")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_config(args)
        self.assertIn("DESCRIPTION=My job", fake_out.getvalue())
        self.assertIn("deploy.conf", mock_run.call_args[0][1])

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_config_cat_env(self, mock_run):
        mock_run.return_value = MagicMock(stdout="FOO=bar\n", returncode=0)
        args = _make_args(job="deploy", type="env", edit=False, command="config")
        with patch("sys.stdout", new_callable=StringIO):
            cmd_config(args)
        self.assertIn("deploy.env", mock_run.call_args[0][1])

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_config_not_found(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", returncode=1)
        args = _make_args(job="deploy", type="conf", edit=False, command="config")
        with self.assertRaises(SystemExit) as ctx:
            cmd_config(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("os.execvp")
    @patch.dict("os.environ", {}, clear=True)
    def test_config_edit_local(self, mock_exec):
        args = _make_args(job="deploy", type="conf", edit=True, command="config")
        cmd_config(args)
        mock_exec.assert_called_once()
        self.assertIn("deploy.conf", mock_exec.call_args[0][1][1])

    @patch("os.execvp")
    @patch.dict("os.environ", {}, clear=True)
    def test_config_edit_ssh(self, mock_exec):
        args = _make_args(job="deploy", type="env", edit=True, remote="server1", command="config")
        cmd_config(args)
        self.assertEqual(mock_exec.call_args[0][0], "ssh")
        self.assertIn("deploy.env", mock_exec.call_args[0][1][3])

    @patch.dict("os.environ", {}, clear=True)
    def test_config_rejects_invalid_job_name(self):
        args = _make_args(job="bad;name", type="conf", edit=False, command="config")
        with self.assertRaises(SystemExit) as ctx:
            cmd_config(args)
        self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# cmd_export
# ===================================================================

class TestCmdExport(unittest.TestCase):
    @patch("subprocess.run")
    @patch("os.path.getsize", return_value=1024)
    @patch.dict("os.environ", {}, clear=True)
    def test_export_success(self, mock_getsize, mock_sp):
        mock_sp.return_value = MagicMock(returncode=0, stderr=b"")
        args = _make_args(output=None, command="export")
        with patch("builtins.open", mock_open()):
            with patch("sys.stdout", new_callable=StringIO) as fake_out:
                cmd_export(args)
        self.assertIn("Exported to:", fake_out.getvalue())
        self.assertIn("1024 bytes", fake_out.getvalue())

    @patch("subprocess.run")
    @patch("os.path.getsize", return_value=2048)
    @patch.dict("os.environ", {}, clear=True)
    def test_export_custom_output(self, mock_getsize, mock_sp):
        mock_sp.return_value = MagicMock(returncode=0, stderr=b"")
        args = _make_args(output="my-backup.tar.gz", command="export")
        with patch("builtins.open", mock_open()) as m_open:
            with patch("sys.stdout", new_callable=StringIO) as fake_out:
                cmd_export(args)
        self.assertIn("my-backup.tar.gz", fake_out.getvalue())
        m_open.assert_called_once_with("my-backup.tar.gz", "wb")

    @patch("subprocess.run")
    @patch("os.path.exists", return_value=True)
    @patch("os.remove")
    @patch.dict("os.environ", {}, clear=True)
    def test_export_failure_cleans_up(self, mock_remove, mock_exists, mock_sp):
        mock_sp.return_value = MagicMock(returncode=1, stderr=b"tar: error")
        args = _make_args(output="fail.tar.gz", command="export")
        with patch("builtins.open", mock_open()):
            with self.assertRaises(SystemExit) as ctx:
                cmd_export(args)
        self.assertEqual(ctx.exception.code, 1)
        mock_remove.assert_called_once_with("fail.tar.gz")

    @patch("subprocess.run")
    @patch("os.path.getsize", return_value=512)
    @patch.dict("os.environ", {}, clear=True)
    def test_export_ssh_mode(self, mock_getsize, mock_sp):
        mock_sp.return_value = MagicMock(returncode=0, stderr=b"")
        args = _make_args(output="backup.tar.gz", remote="server1", command="export")
        with patch("builtins.open", mock_open()):
            with patch("sys.stdout", new_callable=StringIO):
                cmd_export(args)
        call_args = mock_sp.call_args[0][0]
        self.assertEqual(call_args[0], "ssh")
        self.assertEqual(call_args[1], "server1")


# ===================================================================
# cmd_diff
# ===================================================================

class TestCmdDiff(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_identical(self, mock_run):
        content = "#!/bin/bash\necho hello\n"
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout=content),
            MagicMock(returncode=0, stdout=content),
        ]
        args = _make_args(job="build", job2="deploy", local=None, command="diff")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_diff(args)
        self.assertIn("No differences found.", fake_out.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_different(self, mock_run):
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="#!/bin/bash\necho hello\n"),
            MagicMock(returncode=0, stdout="#!/bin/bash\necho world\n"),
        ]
        args = _make_args(job="build", job2="deploy", local=None, command="diff")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_diff(args)
        output = fake_out.getvalue()
        self.assertIn("---", output)
        self.assertIn("+++", output)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_job_not_found(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=1, stdout="")]
        args = _make_args(job="nonexistent", job2="deploy", local=None, command="diff")
        with self.assertRaises(SystemExit) as ctx:
            cmd_diff(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_with_local_file(self, mock_run):
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="#!/bin/bash\necho remote\n"),
        ]
        with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
            f.write("#!/bin/bash\necho local\n")
            local_path = f.name
        try:
            args = _make_args(job="build", job2=None, local=local_path, command="diff")
            with patch("sys.stdout", new_callable=StringIO) as fake_out:
                cmd_diff(args)
            self.assertIn("---", fake_out.getvalue())
        finally:
            os.unlink(local_path)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_local_not_found(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=0, stdout="#!/bin/bash\n")]
        args = _make_args(job="build", job2=None, local="/no/such/file.sh", command="diff")
        with self.assertRaises(SystemExit) as ctx:
            cmd_diff(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_no_second_arg(self, mock_run):
        mock_run.side_effect = [MagicMock(returncode=0, stdout="#!/bin/bash\n")]
        args = _make_args(job="build", job2=None, local=None, command="diff")
        with self.assertRaises(SystemExit) as ctx:
            cmd_diff(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch.dict("os.environ", {}, clear=True)
    def test_diff_rejects_invalid_job_name(self):
        args = _make_args(job="bad;name", job2="deploy", local=None, command="diff")
        with self.assertRaises(SystemExit) as ctx:
            cmd_diff(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_local_permission_error(self, mock_run):
        """Diffing against a file with no read permissions should exit 1."""
        mock_run.side_effect = [MagicMock(returncode=0, stdout="#!/bin/bash\necho hi\n")]
        with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
            f.write("#!/bin/bash\necho secret\n")
            local_path = f.name
        try:
            os.chmod(local_path, 0o000)
            args = _make_args(job="build", job2=None, local=local_path, command="diff")
            with self.assertRaises(SystemExit) as ctx:
                cmd_diff(args)
            self.assertEqual(ctx.exception.code, 1)
        finally:
            os.chmod(local_path, 0o644)
            os.unlink(local_path)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_diff_local_directory(self, mock_run):
        """Diffing against a directory path should exit 1."""
        mock_run.side_effect = [MagicMock(returncode=0, stdout="#!/bin/bash\necho hi\n")]
        with tempfile.TemporaryDirectory() as tmpdir:
            args = _make_args(job="build", job2=None, local=tmpdir, command="diff")
            with self.assertRaises(SystemExit) as ctx:
                cmd_diff(args)
            self.assertEqual(ctx.exception.code, 1)


# ===================================================================
# --json flag
# ===================================================================

class TestJsonFlag(unittest.TestCase):
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_list_json(self, mock_run_py):
        mock_run_py.return_value = [
            {"name": "build", "state": None, "last_run": {"number": 1, "completed": 1000, "result": 5}}
        ]
        args = _make_args(command="list", json=True)
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_list(args)
        parsed = json.loads(fake_out.getvalue())
        self.assertEqual(len(parsed), 1)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_history_json(self, mock_run_py):
        mock_run_py.return_value = [
            {"number": 1, "started": 900, "completed": 1000, "result": 5, "reason": "manual"}
        ]
        args = _make_args(job="build", last=10, command="history", json=True)
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_history(args)
        parsed = json.loads(fake_out.getvalue())
        self.assertEqual(parsed[0]["result"], 5)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_logs_json(self, mock_run_py):
        mock_run_py.return_value = [
            {"number": 1, "started": 900, "completed": 1000, "result": 5, "output": "ok"}
        ]
        args = _make_args(job="build", run=None, last=1, command="logs", json=True)
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_logs(args)
        parsed = json.loads(fake_out.getvalue())
        self.assertEqual(parsed[0]["output"], "ok")


# ===================================================================
# Command injection prevention
# ===================================================================

class TestCommandInjectionPrevention(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_cmd_cat_rejects_semicolon(self, mock_run):
        args = _make_args(job="test; rm -rf /", command="cat")
        with self.assertRaises(SystemExit):
            cmd_cat(args)
        mock_run.assert_not_called()

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_cmd_cat_quotes_path(self, mock_run):
        mock_run.return_value = MagicMock(stdout="#!/bin/bash\n", returncode=0)
        args = _make_args(job="my-build", command="cat")
        cmd_cat(args)
        called_cmd = mock_run.call_args[0][1]
        self.assertIn("'", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_cmd_trigger_rejects_pipe(self, mock_run):
        args = _make_args(job="build|cat /etc/shadow", env=[], queue=False, command="trigger")
        with self.assertRaises(SystemExit):
            cmd_trigger(args)
        mock_run.assert_not_called()

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_cmd_trigger_env_quoted(self, mock_run):
        mock_run.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", env=["FOO=bar; rm -rf /"], queue=False, command="trigger")
        cmd_trigger(args)
        called_cmd = mock_run.call_args[0][1]
        self.assertIn("'FOO=bar; rm -rf /'", called_cmd)


# ===================================================================
# cmd_init -- config preservation (Bug M3)
# ===================================================================

class TestCmdInitConfigPreservation(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_init_executors_preserves_existing_config(self, mock_run):
        # Simulate: mkdir calls, crankd in PATH, config write,
        # mkdir service dir, tee service, daemon-reload, enable-linger,
        # enable, start, is-active
        mock_run.return_value = MagicMock(stdout="", returncode=0)
        mock_run.side_effect = [
            MagicMock(returncode=0),                       # mkdir cfg/jobs
            MagicMock(returncode=0),                       # mkdir cfg/contexts
            MagicMock(returncode=0),                       # mkdir cfg/scripts
            MagicMock(returncode=0),                       # mkdir run
            MagicMock(stdout="/usr/local/bin/crankd\n", returncode=0),  # command -v crankd
            MagicMock(returncode=0),                       # grep -v ... crank.conf
            MagicMock(returncode=0),                       # mkdir service dir
            MagicMock(returncode=0),                       # tee service file
            MagicMock(returncode=0),                       # daemon-reload
            MagicMock(returncode=0),                       # enable-linger
            MagicMock(returncode=0),                       # enable
            MagicMock(returncode=0),                       # start
            MagicMock(stdout="active\n", returncode=0),   # is-active
        ]
        args = _make_args(command="init", no_start=False, executors=4)
        cmd_init(args)
        # Find the call that updates crank.conf — should use grep -v, not plain tee
        conf_calls = [c for c in mock_run.call_args_list
                      if 'crank.conf' in str(c) and 'grep' in str(c)]
        self.assertTrue(len(conf_calls) >= 1, "Expected grep-based config update")
        conf_cmd = conf_calls[0][0][1]
        self.assertIn("grep -v", conf_cmd)
        self.assertIn("CRANK_NUM_EXECUTORS=4", conf_cmd)


# ===================================================================
# cmd_retry with params (env var persistence)
# ===================================================================

class TestCmdRetryWithParams(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_replays_params(self, mock_run_py, mock_run_cmd):
        mock_run_py.return_value = {
            "number": 1, "result": 4,
            "params": '{"FOO": "bar", "BAZ": "qux"}',
        }
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="deploy", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("'FOO=bar'", called_cmd)
        self.assertIn("'BAZ=qux'", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_empty_params(self, mock_run_py, mock_run_cmd):
        mock_run_py.return_value = {"number": 2, "result": 4, "params": ""}
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        # Should just be "crankc run 'build'" with no extra env vars
        self.assertIn("crankc run", called_cmd)
        self.assertNotIn("=", called_cmd.split("crankc run")[1].replace("'build'", ""))

    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_invalid_params_json(self, mock_run_py, mock_run_cmd):
        mock_run_py.return_value = {"number": 3, "result": 3, "params": "not json"}
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)
        self.assertIn("'build'", called_cmd)


# ===================================================================
# cmd_schedule
# ===================================================================

class TestCmdSchedule(unittest.TestCase):
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_shows_scheduled_jobs(self, mock_run_py):
        mock_run_py.return_value = [
            {"name": "deploy", "schedule": "*/5 * * * *"},
        ]
        args = _make_args(command="schedule")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_schedule(args)
        output = fake_out.getvalue()
        self.assertIn("deploy", output)
        self.assertIn("*/5", output)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_no_schedules(self, mock_run_py):
        mock_run_py.return_value = []
        args = _make_args(command="schedule")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_schedule(args)
        self.assertIn("No scheduled jobs found.", fake_out.getvalue())

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_json_output(self, mock_run_py):
        mock_run_py.return_value = [
            {"name": "deploy", "schedule": "*/5 * * * *"},
            {"name": "backup", "schedule": "0 2 * * *"},
        ]
        args = _make_args(command="schedule", json=True)
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_schedule(args)
        parsed = json.loads(fake_out.getvalue())
        self.assertEqual(len(parsed), 2)
        self.assertEqual(parsed[0]["name"], "deploy")
        self.assertEqual(parsed[1]["schedule"], "0 2 * * *")


# ===================================================================
# cmd_history with parent columns
# ===================================================================

class TestCmdHistoryWithParent(unittest.TestCase):
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_history_shows_parent(self, mock_run_py):
        mock_run_py.return_value = [
            {"number": 5, "started": 1705321800, "completed": 1705321900,
             "result": 5, "reason": "pipeline",
             "parent_job": "build", "parent_build": 3},
        ]
        args = _make_args(job="deploy", last=10, command="history")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_history(args)
        output = fake_out.getvalue()
        self.assertIn("Parent", output)
        self.assertIn("build#3", output)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_history_no_parent(self, mock_run_py):
        mock_run_py.return_value = [
            {"number": 2, "started": 1705321800, "completed": 1705321900,
             "result": 4, "reason": "manual",
             "parent_job": "", "parent_build": 0},
        ]
        args = _make_args(job="deploy", last=10, command="history")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_history(args)
        output = fake_out.getvalue()
        self.assertIn("Parent", output)
        # The parent column should show "-" when no parent
        lines = output.strip().split("\n")
        data_line = lines[-1]  # last line is the data row
        # Split on whitespace and check the last column is exactly "-"
        last_col = data_line.split()[-1]
        self.assertEqual(last_col, "-", f"Last column (parent) should be '-', got {last_col!r}")


# ===================================================================
# Fix: cmd_retry with non-dict params doesn't crash
# ===================================================================

class TestCmdRetryNonDictParams(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_params_is_list_not_dict(self, mock_run_py, mock_run_cmd):
        """If params column contains a JSON list instead of dict, retry should not crash."""
        mock_run_py.return_value = {
            "number": 5, "result": 4,
            "params": '["FOO=bar"]',
        }
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run=None, command="retry")
        # Should not raise — isinstance(params, dict) check protects it
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)
        # No env vars should be passed since params is not a dict
        remainder = called_cmd.split("crankc run")[1].replace("'build'", "").strip()
        self.assertEqual(remainder, "")

    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_params_is_string_not_json(self, mock_run_py, mock_run_cmd):
        """If params column contains a non-JSON string, retry should not crash."""
        mock_run_py.return_value = {
            "number": 5, "result": 4,
            "params": "not json at all",
        }
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_params_is_none(self, mock_run_py, mock_run_cmd):
        """If params is None (from DB), retry should not crash."""
        mock_run_py.return_value = {
            "number": 5, "result": 4,
            "params": None,
        }
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)

    @patch("crank_cli.main._run_cmd")
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_retry_params_is_integer(self, mock_run_py, mock_run_cmd):
        """If params column contains a JSON integer, retry should not crash."""
        mock_run_py.return_value = {
            "number": 5, "result": 4,
            "params": "42",
        }
        mock_run_cmd.return_value = MagicMock(stdout="", returncode=0)
        args = _make_args(job="build", run=None, command="retry")
        cmd_retry(args)
        called_cmd = mock_run_cmd.call_args[0][1]
        self.assertIn("crankc run", called_cmd)


# ===================================================================
# Fix: cmd_history duration is integer (not float)
# ===================================================================

class TestCmdHistoryDurationInteger(unittest.TestCase):
    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_duration_is_integer_seconds(self, mock_run_py):
        """Duration should be displayed as integer seconds (e.g., '100s' not '100.0s')."""
        mock_run_py.return_value = [
            {"number": 1, "started": 1000, "completed": 1100,
             "result": 5, "reason": "manual",
             "parent_job": "", "parent_build": 0},
        ]
        args = _make_args(job="build", last=10, command="history")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_history(args)
        output = fake_out.getvalue()
        # The duration should be "100s" (int), not "100.0s" (float)
        self.assertIn("100s", output)
        self.assertNotIn("100.0s", output)

    @patch("crank_cli.main._run_python")
    @patch.dict("os.environ", {}, clear=True)
    def test_duration_dash_when_no_timestamps(self, mock_run_py):
        """Duration should be '-' when started or completed is missing."""
        mock_run_py.return_value = [
            {"number": 1, "started": None, "completed": None,
             "result": 1, "reason": "manual",
             "parent_job": "", "parent_build": 0},
        ]
        args = _make_args(job="build", last=10, command="history")
        with patch("sys.stdout", new_callable=StringIO) as fake_out:
            cmd_history(args)
        output = fake_out.getvalue()
        lines = output.strip().split("\n")
        data_line = lines[-1]
        # Duration column should contain "-"
        self.assertIn("-", data_line)


# ===================================================================
# Fix: cmd_trigger prints crankc stderr on failure
# ===================================================================

class TestCmdTriggerStderrOnFailure(unittest.TestCase):
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_trigger_prints_stderr_on_failure(self, mock_run):
        """When crankc exits non-zero, its stderr should be printed."""
        mock_run.return_value = MagicMock(
            stdout="", returncode=1,
            stderr="crankc: build #5 failed\n",
        )
        args = _make_args(job="deploy", env=[], queue=False, command="trigger")
        with patch("sys.stderr", new_callable=StringIO) as fake_err:
            with self.assertRaises(SystemExit) as ctx:
                cmd_trigger(args)
            self.assertEqual(ctx.exception.code, 1)
            self.assertIn("build #5 failed", fake_err.getvalue())

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_trigger_no_stderr_on_success(self, mock_run):
        """On success, no stderr should be printed."""
        mock_run.return_value = MagicMock(
            stdout="OK 1\n", returncode=0,
            stderr="",
        )
        args = _make_args(job="deploy", env=[], queue=False, command="trigger")
        with patch("sys.stderr", new_callable=StringIO) as fake_err:
            cmd_trigger(args)
            self.assertEqual(fake_err.getvalue(), "")

    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_trigger_empty_stderr_on_failure(self, mock_run):
        """When crankc exits non-zero but stderr is empty, should still exit with code."""
        mock_run.return_value = MagicMock(
            stdout="", returncode=3,
            stderr="",
        )
        args = _make_args(job="deploy", env=[], queue=False, command="trigger")
        with self.assertRaises(SystemExit) as ctx:
            cmd_trigger(args)
        self.assertEqual(ctx.exception.code, 3)


# ===================================================================
# Fix: cmd_tail timeout on log file wait
# ===================================================================

class TestCmdTailLogFileTimeout(unittest.TestCase):
    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_times_out_waiting_for_log_file(self, mock_run_cmd, mock_run_py, mock_time):
        """If the log file never appears and the build is still running,
        cmd_tail should time out after 120 seconds."""
        mock_time.sleep = MagicMock()
        # time.time() returns values that exceed the 120s deadline
        mock_time.time = MagicMock(side_effect=[
            1000.0,   # start_time (used for tail_deadline = 1000+120 = 1120)
            1000.0,   # first loop iteration
            1200.0,   # second iteration — past deadline
        ])
        # test -f always fails (log never appears)
        mock_run_cmd.side_effect = [
            MagicMock(returncode=1),  # initial test -f
            MagicMock(returncode=1),  # test -f in loop
        ]
        # Build status: still running
        mock_run_py.side_effect = [
            {"number": 7},   # _run_python for finding running build number (not needed since we pass run=)
            2,                # build_check: result=2 (running)
        ]
        args = _make_args(job="build", run="7", command="tail")
        with self.assertRaises(SystemExit) as ctx:
            cmd_tail(args)
        self.assertEqual(ctx.exception.code, 1)

    @patch("crank_cli.main.time")
    @patch("crank_cli.main._run_python")
    @patch("crank_cli.main._run_cmd")
    @patch.dict("os.environ", {}, clear=True)
    def test_tail_exits_if_build_completes_without_log(self, mock_run_cmd, mock_run_py, mock_time):
        """If the build completes before a log file appears, cmd_tail should exit."""
        mock_time.sleep = MagicMock()
        mock_time.time = MagicMock(side_effect=[
            1000.0,   # for tail_deadline
            1001.0,   # loop check
        ])
        # test -f fails both times
        mock_run_cmd.side_effect = [
            MagicMock(returncode=1),  # initial test -f
            MagicMock(returncode=1),  # test -f in loop
        ]
        # Build completed (result=5 = success, which is not in (1,2))
        mock_run_py.return_value = 5
        args = _make_args(job="build", run="7", command="tail")
        with self.assertRaises(SystemExit) as ctx:
            cmd_tail(args)
        self.assertEqual(ctx.exception.code, 1)


if __name__ == "__main__":
    unittest.main()
