from unittest.mock import MagicMock, patch

from click.testing import CliRunner

from sitedrop.cli import cli
from sitedrop.client.api import SitedropError
from sitedrop.server.config import ServerConfig


def test_version():
    runner = CliRunner()
    result = runner.invoke(cli, ["--version"])
    assert result.exit_code == 0
    assert "0.0.5" in result.output


def test_help():
    runner = CliRunner()
    result = runner.invoke(cli, ["--help"])
    assert result.exit_code == 0
    assert "upload" in result.output
    assert "login" in result.output
    assert "setup" in result.output


def test_all_commands_registered():
    runner = CliRunner()
    result = runner.invoke(cli, ["--help"])
    for cmd in [
        "setup",
        "start",
        "stop",
        "status",
        "restart",
        "logs",
        "backup",
        "restore",
        "login",
        "upload",
        "list",
        "delete",
        "password",
        "info",
    ]:
        assert cmd in result.output, f"{cmd} not in help output"


def test_upload_no_login(tmp_path):
    runner = CliRunner()
    html = tmp_path / "test.html"
    html.write_text("<h1>Test</h1>")
    rc = tmp_path / ".sitedroprc"
    with patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc):
        result = runner.invoke(cli, ["upload", str(html)])
    assert result.exit_code != 0


def test_list_no_login(tmp_path):
    runner = CliRunner()
    rc = tmp_path / ".sitedroprc"
    with patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc):
        result = runner.invoke(cli, ["list"])
    assert result.exit_code != 0


def test_delete_no_login(tmp_path):
    runner = CliRunner()
    rc = tmp_path / ".sitedroprc"
    with patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc):
        result = runner.invoke(cli, ["delete", "--yes", "something"])
    assert result.exit_code != 0


def test_upload_file_not_found():
    runner = CliRunner()
    result = runner.invoke(cli, ["upload", "/nonexistent/file.html"])
    assert result.exit_code != 0


def test_setup_dev(tmp_path):
    config_path = tmp_path / "server.json"
    with (
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_DIR", tmp_path),
        patch("sitedrop.server.config.DEFAULT_CONFIG_PATH", config_path),
    ):
        runner = CliRunner()
        result = runner.invoke(
            cli,
            ["setup", "--dev"],
            input="mypassword\nmypassword\n",
        )
    assert result.exit_code == 0
    assert "127.0.0.1" in result.output or "development" in result.output.lower()


def test_status_not_running(tmp_path):
    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", tmp_path / "server.pid"),
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_DIR", tmp_path),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "not running" in result.output.lower()


def test_stop_not_running(tmp_path):
    with patch("sitedrop.commands.server_cmds.PID_FILE", tmp_path / "server.pid"):
        runner = CliRunner()
        result = runner.invoke(cli, ["stop"])
    assert result.exit_code == 0
    assert "not running" in result.output.lower()


def test_start_without_setup(tmp_path):
    empty_config = ServerConfig()  # no password_hash
    with patch(
        "sitedrop.commands.server_cmds.ServerConfig.load", return_value=empty_config
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["start", "--dev"])
    assert result.exit_code != 0
    assert "setup" in result.output.lower()


def test_status_corrupted_pid(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("not-a-number")
    with patch("sitedrop.commands.server_cmds.PID_FILE", pid_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "not running" in result.output.lower()
    assert not pid_file.exists()


def test_stop_corrupted_pid(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("garbage\n")
    with patch("sitedrop.commands.server_cmds.PID_FILE", pid_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["stop"])
    assert result.exit_code == 0
    assert "not running" in result.output.lower()


def test_start_corrupted_pid(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("???")
    empty_config = ServerConfig()
    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch(
            "sitedrop.commands.server_cmds.ServerConfig.load", return_value=empty_config
        ),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["start", "--dev"])
    # Should not crash on the corrupted PID — it gets cleaned up
    # It fails because no password is set, not because of PID
    assert "setup" in result.output.lower()


def test_stop_graceful_shutdown(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("12345")
    call_count = 0

    def mock_is_running(pid):
        nonlocal call_count
        call_count += 1
        # First call from _read_pid context doesn't happen, but poll loop calls:
        # Return False on second call to simulate process exiting after SIGTERM
        return call_count <= 1

    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds._is_sitedrop_process", return_value=True),
        patch("sitedrop.commands.server_cmds._is_running", side_effect=mock_is_running),
        patch("os.kill") as mock_kill,
        patch("sitedrop.commands.server_cmds.time.sleep"),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["stop"])
    assert result.exit_code == 0
    assert "stopped" in result.output.lower()
    # SIGTERM was sent, not SIGKILL
    mock_kill.assert_called_once_with(12345, __import__("signal").SIGTERM)
    assert not pid_file.exists()


def test_stop_fallback_to_sigkill(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("12345")

    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds._is_sitedrop_process", return_value=True),
        patch("sitedrop.commands.server_cmds._is_running", return_value=True),
        patch("os.kill") as mock_kill,
        patch("sitedrop.commands.server_cmds.time.sleep"),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["stop"])
    assert result.exit_code == 0
    assert "force-killed" in result.output.lower()
    # Verify SIGTERM then SIGKILL
    import signal

    calls = mock_kill.call_args_list
    assert calls[0] == ((12345, signal.SIGTERM),)
    assert calls[1] == ((12345, signal.SIGKILL),)
    assert not pid_file.exists()


def test_start_logs_to_file(tmp_path):
    config = ServerConfig(password_hash="hashed", host="127.0.0.1", port=8000)
    pid_file = tmp_path / "server.pid"
    log_file = tmp_path / "server.log"
    mock_proc = MagicMock()
    mock_proc.poll.return_value = None
    mock_proc.pid = 99999

    with (
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds.LOG_FILE", log_file),
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_DIR", tmp_path),
        patch("sitedrop.commands.server_cmds._read_pid", return_value=None),
        patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
        patch("sitedrop.commands.server_cmds.time.sleep"),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["start"])
    assert result.exit_code == 0
    assert "99999" in result.output
    assert "log" in result.output.lower()
    # Verify Popen was called with file handles, not PIPE
    popen_kwargs = mock_popen.call_args
    assert popen_kwargs.kwargs.get("stdout") is not None
    assert popen_kwargs.kwargs.get("stdout") != __import__("subprocess").PIPE


# --- info command tests ---


def test_info_not_logged_in():
    from sitedrop.client.config import ClientConfig

    runner = CliRunner()
    with patch(
        "sitedrop.commands.client_cmds.ClientConfig.load",
        return_value=ClientConfig(),
    ):
        result = runner.invoke(cli, ["info"])
    assert result.exit_code == 0
    assert "not logged in" in result.output.lower()


def test_info_valid_token():
    import jwt as pyjwt
    from datetime import datetime, timedelta, timezone
    from sitedrop.client.config import ClientConfig

    exp = datetime.now(timezone.utc) + timedelta(hours=12)
    token = pyjwt.encode(
        {"exp": exp, "iat": datetime.now(timezone.utc)},
        "test-secret-that-is-long-enough-for-hs256",
        algorithm="HS256",
    )
    config = ClientConfig(
        server_url="http://localhost:8000", token=token, password="pass"
    )

    runner = CliRunner()
    with patch("sitedrop.commands.client_cmds.ClientConfig.load", return_value=config):
        result = runner.invoke(cli, ["info"])
    assert result.exit_code == 0
    assert "http://localhost:8000" in result.output
    assert "valid until" in result.output.lower()
    assert "remaining" in result.output.lower()


def test_info_expired_token():
    import jwt as pyjwt
    from datetime import datetime, timedelta, timezone
    from sitedrop.client.config import ClientConfig

    exp = datetime.now(timezone.utc) - timedelta(hours=1)
    token = pyjwt.encode(
        {"exp": exp, "iat": datetime.now(timezone.utc) - timedelta(hours=25)},
        "test-secret-that-is-long-enough-for-hs256",
        algorithm="HS256",
    )
    config = ClientConfig(
        server_url="http://localhost:8000", token=token, password="pass"
    )

    runner = CliRunner()
    with patch("sitedrop.commands.client_cmds.ClientConfig.load", return_value=config):
        result = runner.invoke(cli, ["info"])
    assert result.exit_code == 0
    assert "expired" in result.output.lower()


def test_info_invalid_token():
    from sitedrop.client.config import ClientConfig

    config = ClientConfig(
        server_url="http://localhost:8000", token="not.a.valid.jwt", password="pass"
    )

    runner = CliRunner()
    with patch("sitedrop.commands.client_cmds.ClientConfig.load", return_value=config):
        result = runner.invoke(cli, ["info"])
    assert result.exit_code == 0
    assert "invalid" in result.output.lower()


# --- delete confirmation tests ---


def test_delete_abort_on_no(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )

    with patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc):
        runner = CliRunner()
        result = runner.invoke(cli, ["delete", "mysite"], input="n\n")
    assert result.exit_code == 0
    assert "aborted" in result.output.lower()


def test_delete_proceed_on_yes_input(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    mock_client = MagicMock()
    mock_client.delete.return_value = "Deleted mysite"

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["delete", "mysite"], input="y\n")
    assert result.exit_code == 0
    assert "deleted mysite" in result.output.lower()


def test_delete_skip_prompt_with_yes_flag(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    mock_client = MagicMock()
    mock_client.delete.return_value = "Deleted mysite"

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["delete", "--yes", "mysite"])
    assert result.exit_code == 0
    assert "confirm" not in result.output.lower()
    assert "deleted mysite" in result.output.lower()


# --- dynamic list column tests ---


def test_list_long_name_not_truncated(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    long_name = "a-very-long-site-name-that-exceeds-twenty-characters"
    mock_client = MagicMock()
    mock_client.list_sites.return_value = [
        {"name": long_name, "size": 1234, "modified": "2024-01-01"},
    ]

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["list"])
    assert result.exit_code == 0
    assert long_name in result.output
    # Verify the name is not truncated (full name appears on a single line)
    for line in result.output.splitlines():
        if long_name in line:
            assert "1234" in line
            break


# --- restart command tests ---


def test_restart_when_not_running(tmp_path):
    config = ServerConfig()  # no password_hash
    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", tmp_path / "server.pid"),
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["restart"])
    # stop says "not running", start fails because no password
    assert "not running" in result.output.lower()


def test_restart_registered_in_help():
    runner = CliRunner()
    result = runner.invoke(cli, ["restart", "--help"])
    assert result.exit_code == 0
    assert "restart" in result.output.lower()


# --- password command tests ---


def test_password_command_registered():
    runner = CliRunner()
    result = runner.invoke(cli, ["--help"])
    assert "password" in result.output


def test_password_success(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "oldpass",
            }
        )
    )
    mock_client = MagicMock()
    mock_client.change_password.return_value = {
        "message": "Password updated",
        "token": "new-token",
    }
    mock_client.config = MagicMock()

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["password"], input="oldpass\nnewpass\nnewpass\n")
    assert result.exit_code == 0
    assert "password updated" in result.output.lower()
    mock_client.change_password.assert_called_once_with("oldpass", "newpass")
    assert mock_client.config.password == "newpass"
    mock_client.config.save.assert_called_once()


def test_password_wrong_current(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    mock_client = MagicMock()
    mock_client.change_password.side_effect = SitedropError(
        "Authentication failed: Current password is incorrect"
    )

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["password"], input="wrong\nnewpass\nnewpass\n")
    assert result.exit_code != 0


def test_password_not_logged_in(tmp_path):
    rc = tmp_path / ".sitedroprc"
    with patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc):
        runner = CliRunner()
        result = runner.invoke(cli, ["password"], input="old\nnew\nnew\n")
    assert result.exit_code != 0


# --- upload --force tests ---


def test_upload_force_flag(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    html = tmp_path / "test.html"
    html.write_text("<h1>Test</h1>")
    mock_client = MagicMock()
    mock_client.upload.return_value = {"name": "test", "size": 15}
    mock_client.base_url = "http://localhost:8000"

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["upload", "--force", str(html)])
    assert result.exit_code == 0
    mock_client.upload.assert_called_once_with("test", "<h1>Test</h1>", force=True)


def test_upload_overwrite_prompt_yes(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    html = tmp_path / "test.html"
    html.write_text("<h1>Test</h1>")
    mock_client = MagicMock()
    # First call raises 412, second call succeeds
    mock_client.upload.side_effect = [
        SitedropError("Site already exists: Site 'test' already exists."),
        {"name": "test", "size": 15},
    ]
    mock_client.base_url = "http://localhost:8000"

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["upload", str(html)], input="y\n")
    assert result.exit_code == 0
    assert "uploaded" in result.output.lower()
    assert mock_client.upload.call_count == 2


def test_upload_overwrite_prompt_no(tmp_path):
    rc = tmp_path / ".sitedroprc"
    rc.write_text(
        __import__("json").dumps(
            {
                "server_url": "http://localhost:8000",
                "token": "tok",
                "password": "pass",
            }
        )
    )
    html = tmp_path / "test.html"
    html.write_text("<h1>Test</h1>")
    mock_client = MagicMock()
    mock_client.upload.side_effect = SitedropError(
        "Site already exists: Site 'test' already exists."
    )

    with (
        patch("sitedrop.client.config.DEFAULT_CLIENT_CONFIG", rc),
        patch("sitedrop.commands.client_cmds.SitedropClient", return_value=mock_client),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["upload", str(html)], input="n\n")
    assert result.exit_code == 0
    assert "aborted" in result.output.lower()


# --- PID staleness detection tests ---


def test_is_sitedrop_process_correct_cmdline():
    from sitedrop.commands.server_cmds import _is_sitedrop_process

    cmdline = "python\x00-m\x00uvicorn\x00sitedrop.server.app:create_app\x00--factory"
    with (
        patch("os.kill"),
        patch("builtins.open", return_value=__import__("io").StringIO(cmdline)),
    ):
        assert _is_sitedrop_process(12345) is True


def test_is_sitedrop_process_wrong_cmdline():
    from sitedrop.commands.server_cmds import _is_sitedrop_process

    cmdline = "python\x00-m\x00flask\x00run"
    with (
        patch("os.kill"),
        patch("builtins.open", return_value=__import__("io").StringIO(cmdline)),
    ):
        assert _is_sitedrop_process(12345) is False


def test_is_sitedrop_process_proc_unavailable():
    from sitedrop.commands.server_cmds import _is_sitedrop_process

    with (
        patch("os.kill"),
        patch("builtins.open", side_effect=FileNotFoundError),
    ):
        assert _is_sitedrop_process(12345) is True


def test_is_sitedrop_process_dead():
    from sitedrop.commands.server_cmds import _is_sitedrop_process

    with patch("os.kill", side_effect=OSError):
        assert _is_sitedrop_process(12345) is False


def test_status_stale_pid(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("12345")
    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_DIR", tmp_path),
        patch("sitedrop.commands.server_cmds._is_sitedrop_process", return_value=False),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "stale" in result.output.lower()
    assert not pid_file.exists()


def test_stop_stale_pid(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("12345")
    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds._is_sitedrop_process", return_value=False),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["stop"])
    assert result.exit_code == 0
    assert "stale" in result.output.lower()
    assert not pid_file.exists()


def test_start_stale_pid_allows_start(tmp_path):
    pid_file = tmp_path / "server.pid"
    pid_file.write_text("12345")
    config = ServerConfig(password_hash="hashed", host="127.0.0.1", port=8000)
    mock_proc = MagicMock()
    mock_proc.poll.return_value = None
    mock_proc.pid = 99999

    with (
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds.LOG_FILE", tmp_path / "server.log"),
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_DIR", tmp_path),
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
        patch("sitedrop.commands.server_cmds._is_sitedrop_process", return_value=False),
        patch("subprocess.Popen", return_value=mock_proc),
        patch("sitedrop.commands.server_cmds.time.sleep"),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["start"])
    assert result.exit_code == 0
    assert "99999" in result.output


# --- SSL flag tests ---


def test_ssl_flags_passed_to_uvicorn(tmp_path):
    config = ServerConfig(password_hash="hashed", host="127.0.0.1", port=8000)
    pid_file = tmp_path / "server.pid"
    log_file = tmp_path / "server.log"
    certfile = tmp_path / "cert.pem"
    keyfile = tmp_path / "key.pem"
    certfile.write_text("cert")
    keyfile.write_text("key")
    mock_proc = MagicMock()
    mock_proc.poll.return_value = None
    mock_proc.pid = 99999

    with (
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
        patch("sitedrop.commands.server_cmds.PID_FILE", pid_file),
        patch("sitedrop.commands.server_cmds.LOG_FILE", log_file),
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_DIR", tmp_path),
        patch("sitedrop.commands.server_cmds._read_pid", return_value=None),
        patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
        patch("sitedrop.commands.server_cmds.time.sleep"),
    ):
        runner = CliRunner()
        result = runner.invoke(
            cli,
            ["start", "--ssl-certfile", str(certfile), "--ssl-keyfile", str(keyfile)],
        )
    assert result.exit_code == 0
    cmd = mock_popen.call_args[0][0]
    assert "--ssl-certfile" in cmd
    assert "--ssl-keyfile" in cmd


def test_ssl_only_one_flag_errors(tmp_path):
    config = ServerConfig(password_hash="hashed")
    with patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config):
        runner = CliRunner()
        result = runner.invoke(cli, ["start", "--ssl-certfile", "cert.pem"])
    assert result.exit_code != 0
    assert "together" in result.output.lower()


# --- log rotation tests ---


def test_rotate_log_large_file(tmp_path):
    from sitedrop.commands.server_cmds import _rotate_log_if_needed

    log_file = tmp_path / "server.log"
    log_backup = tmp_path / "server.log.1"
    log_file.write_bytes(b"x" * (6 * 1024 * 1024))  # 6 MB

    with (
        patch("sitedrop.commands.server_cmds.LOG_FILE", log_file),
        patch("sitedrop.commands.server_cmds.LOG_BACKUP", log_backup),
    ):
        _rotate_log_if_needed()

    assert not log_file.exists()
    assert log_backup.exists()
    assert log_backup.stat().st_size == 6 * 1024 * 1024


def test_rotate_log_small_file(tmp_path):
    from sitedrop.commands.server_cmds import _rotate_log_if_needed

    log_file = tmp_path / "server.log"
    log_backup = tmp_path / "server.log.1"
    log_file.write_bytes(b"x" * 1000)

    with (
        patch("sitedrop.commands.server_cmds.LOG_FILE", log_file),
        patch("sitedrop.commands.server_cmds.LOG_BACKUP", log_backup),
    ):
        _rotate_log_if_needed()

    assert log_file.exists()
    assert not log_backup.exists()


def test_rotate_log_no_file(tmp_path):
    from sitedrop.commands.server_cmds import _rotate_log_if_needed

    log_file = tmp_path / "server.log"
    with patch("sitedrop.commands.server_cmds.LOG_FILE", log_file):
        _rotate_log_if_needed()  # should not raise


def test_rotate_log_replaces_existing_backup(tmp_path):
    from sitedrop.commands.server_cmds import _rotate_log_if_needed

    log_file = tmp_path / "server.log"
    log_backup = tmp_path / "server.log.1"
    log_file.write_bytes(b"new" * (2 * 1024 * 1024))
    log_backup.write_bytes(b"old" * 100)

    with (
        patch("sitedrop.commands.server_cmds.LOG_FILE", log_file),
        patch("sitedrop.commands.server_cmds.LOG_BACKUP", log_backup),
    ):
        _rotate_log_if_needed()

    assert not log_file.exists()
    assert log_backup.exists()
    assert log_backup.stat().st_size == 6 * 1024 * 1024


# --- logs command tests ---


def test_logs_registered():
    runner = CliRunner()
    result = runner.invoke(cli, ["logs", "--help"])
    assert result.exit_code == 0
    assert "--follow" in result.output or "-f" in result.output


def test_logs_last_20_lines(tmp_path):
    log_file = tmp_path / "server.log"
    log_file.write_text("\n".join(f"line {i}" for i in range(50)) + "\n")

    with patch("sitedrop.commands.server_cmds.LOG_FILE", log_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["logs"])
    assert result.exit_code == 0
    assert "line 30" in result.output
    assert "line 49" in result.output
    assert "line 29" not in result.output


def test_logs_custom_line_count(tmp_path):
    log_file = tmp_path / "server.log"
    log_file.write_text("\n".join(f"line {i}" for i in range(50)) + "\n")

    with patch("sitedrop.commands.server_cmds.LOG_FILE", log_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["logs", "-n", "5"])
    assert result.exit_code == 0
    assert "line 45" in result.output
    assert "line 49" in result.output
    assert "line 44" not in result.output


def test_logs_fewer_than_requested(tmp_path):
    log_file = tmp_path / "server.log"
    log_file.write_text("line 1\nline 2\nline 3\n")

    with patch("sitedrop.commands.server_cmds.LOG_FILE", log_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["logs"])
    assert result.exit_code == 0
    assert "line 1" in result.output
    assert "line 3" in result.output


def test_logs_no_log_file(tmp_path):
    log_file = tmp_path / "server.log"
    with patch("sitedrop.commands.server_cmds.LOG_FILE", log_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["logs"])
    assert result.exit_code == 0
    assert "no log file" in result.output.lower()


def test_logs_empty_file(tmp_path):
    log_file = tmp_path / "server.log"
    log_file.write_text("")

    with patch("sitedrop.commands.server_cmds.LOG_FILE", log_file):
        runner = CliRunner()
        result = runner.invoke(cli, ["logs"])
    assert result.exit_code == 0


# --- backup command tests ---


def test_backup_registered():
    runner = CliRunner()
    result = runner.invoke(cli, ["backup", "--help"])
    assert result.exit_code == 0
    assert "output" in result.output.lower()


def test_backup_creates_archive(tmp_path):
    import tarfile

    config_path = tmp_path / "server.json"
    sites_dir = tmp_path / "sites"
    sites_dir.mkdir()
    (sites_dir / "mysite.html").write_text("<h1>Hi</h1>")
    config = ServerConfig(password_hash="hashed", sites_dir=str(sites_dir))
    config.save(config_path)
    output = tmp_path / "backup.tar.gz"

    with (
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_PATH", config_path),
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["backup", "-o", str(output)])
    assert result.exit_code == 0
    assert output.exists()

    with tarfile.open(output, "r:gz") as tar:
        names = tar.getnames()
    assert "server.json" in names
    assert "sites/mysite.html" in names


def test_backup_no_config(tmp_path):
    config_path = tmp_path / "nonexistent.json"
    with patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_PATH", config_path):
        runner = CliRunner()
        result = runner.invoke(cli, ["backup"])
    assert result.exit_code != 0
    assert "setup" in result.output.lower()


def test_backup_default_filename(tmp_path):
    config_path = tmp_path / "server.json"
    sites_dir = tmp_path / "sites"
    sites_dir.mkdir()
    config = ServerConfig(password_hash="hashed", sites_dir=str(sites_dir))
    config.save(config_path)

    with (
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_PATH", config_path),
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
    ):
        runner = CliRunner()
        with runner.isolated_filesystem():
            result = runner.invoke(cli, ["backup"])
            assert result.exit_code == 0
            assert "sitedrop-backup-" in result.output


# --- restore command tests ---


def test_restore_registered():
    runner = CliRunner()
    result = runner.invoke(cli, ["restore", "--help"])
    assert result.exit_code == 0
    assert "file" in result.output.lower()


def test_restore_extracts_files(tmp_path):
    import json
    import tarfile

    # Create a backup archive
    archive = tmp_path / "backup.tar.gz"
    config_data = json.dumps({"password_hash": "restored", "config_version": 1})
    site_html = "<h1>Restored</h1>"

    with tarfile.open(archive, "w:gz") as tar:
        import io

        # Add server.json
        info = tarfile.TarInfo(name="server.json")
        data = config_data.encode()
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

        # Add sites/test.html
        info = tarfile.TarInfo(name="sites/test.html")
        data = site_html.encode()
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

    config_path = tmp_path / "restored" / "server.json"
    sites_dir = tmp_path / "restored" / "sites"
    config = ServerConfig(sites_dir=str(sites_dir))

    with (
        patch("sitedrop.commands.server_cmds.DEFAULT_CONFIG_PATH", config_path),
        patch("sitedrop.commands.server_cmds.ServerConfig.load", return_value=config),
        patch("sitedrop.commands.server_cmds._read_pid", return_value=None),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["restore", "--yes", str(archive)])
    assert result.exit_code == 0
    assert "restore complete" in result.output.lower()
    assert config_path.exists()
    assert (sites_dir / "test.html").exists()
    assert (sites_dir / "test.html").read_text() == site_html


def test_restore_warns_if_running(tmp_path):
    import tarfile
    import io

    archive = tmp_path / "backup.tar.gz"
    with tarfile.open(archive, "w:gz") as tar:
        info = tarfile.TarInfo(name="server.json")
        data = b"{}"
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

    with (
        patch("sitedrop.commands.server_cmds._read_pid", return_value=12345),
        patch("sitedrop.commands.server_cmds._is_sitedrop_process", return_value=True),
    ):
        runner = CliRunner()
        result = runner.invoke(cli, ["restore", str(archive)], input="n\n")
    assert result.exit_code == 0
    assert "aborted" in result.output.lower()


def test_restore_rejects_path_traversal(tmp_path):
    import tarfile
    import io

    archive = tmp_path / "evil.tar.gz"
    with tarfile.open(archive, "w:gz") as tar:
        info = tarfile.TarInfo(name="../etc/passwd")
        data = b"evil"
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

    runner = CliRunner()
    result = runner.invoke(cli, ["restore", "--yes", str(archive)])
    assert result.exit_code != 0
    assert "unsafe path" in result.output.lower()


def test_restore_invalid_archive(tmp_path):
    bad_file = tmp_path / "notanarchive.tar.gz"
    bad_file.write_text("this is not a tar file")

    runner = CliRunner()
    result = runner.invoke(cli, ["restore", "--yes", str(bad_file)])
    assert result.exit_code != 0
    assert "could not open" in result.output.lower()
