import subprocess
from pathlib import Path
from unittest.mock import Mock, patch

import pytest

from fastmcp.cli.cli import _parse_env_var, app


class TestMainCLI:
    """Test the main CLI application."""

    def test_app_exists(self):
        """Test that the main app is properly configured."""
        # app.name is a tuple in cyclopts
        assert "fastmcp" in app.name
        assert "FastMCP" in app.help
        # Just check that version exists, not the specific value
        assert hasattr(app, "version")

    def test_parse_env_var_valid(self):
        """Test parsing valid environment variables."""
        key, value = _parse_env_var("KEY=value")
        assert key == "KEY"
        assert value == "value"

        key, value = _parse_env_var("COMPLEX_KEY=complex=value=with=equals")
        assert key == "COMPLEX_KEY"
        assert value == "complex=value=with=equals"

    def test_parse_env_var_invalid(self):
        """Test parsing invalid environment variables exits."""
        with pytest.raises(SystemExit) as exc_info:
            _parse_env_var("INVALID_FORMAT")
        assert isinstance(exc_info.value, SystemExit)
        assert exc_info.value.code == 1


class TestVersionCommand:
    """Test the version command."""

    @patch("fastmcp.cli.cli.check_for_newer_version", return_value=None)
    def test_version_command_execution(self, mock_check):
        """Test that version command executes properly."""
        # The version command should execute without raising SystemExit
        command, bound, _ = app.parse_args(["version"])
        command()  # Should not raise

    def test_version_command_parsing(self):
        """Test that the version command parses arguments correctly."""
        command, bound, _ = app.parse_args(["version"])
        assert callable(command)
        assert command.__name__ == "version"  # type: ignore[attr-defined]  # ty:ignore[unresolved-attribute]
        # Default arguments aren't included in bound.arguments
        assert bound.arguments == {}

    def test_version_command_with_copy_flag(self):
        """Test that the version command parses --copy flag correctly."""
        command, bound, _ = app.parse_args(["version", "--copy"])
        assert callable(command)
        assert command.__name__ == "version"  # type: ignore[attr-defined]  # ty:ignore[unresolved-attribute]
        assert bound.arguments == {"copy": True}

    @patch("fastmcp.cli.cli.pyperclip.copy")
    @patch("fastmcp.cli.cli.console")
    def test_version_command_copy_functionality(
        self, mock_console, mock_pyperclip_copy
    ):
        """Test that the version command copies to clipboard when --copy is used."""
        command, bound, _ = app.parse_args(["version", "--copy"])
        command(**bound.arguments)

        # Verify pyperclip.copy was called with plain text format
        mock_pyperclip_copy.assert_called_once()
        copied_text = mock_pyperclip_copy.call_args[0][0]

        # Verify the copied text contains expected version info keys in plain text
        assert "FastMCP version:" in copied_text
        assert "MCP version:" in copied_text
        assert "Python version:" in copied_text
        assert "Platform:" in copied_text
        assert "FastMCP root path:" in copied_text

        # Verify no ANSI escape codes (terminal control characters)
        assert "\x1b[" not in copied_text
        mock_console.print.assert_called_with(
            "[green]✓[/green] Version information copied to clipboard"
        )


class TestDevCommand:
    """Test the dev command."""

    def test_dev_inspector_command_parsing(self):
        """Test that dev inspector command can be parsed with various options."""
        # Test basic parsing
        command, bound, _ = app.parse_args(["dev", "inspector", "server.py"])
        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"

        # Test with options
        command, bound, _ = app.parse_args(
            [
                "dev",
                "inspector",
                "server.py",
                "--with",
                "package1",
                "--inspector-version",
                "1.0.0",
                "--ui-port",
                "3000",
            ]
        )
        assert bound.arguments["with_packages"] == ["package1"]
        assert bound.arguments["inspector_version"] == "1.0.0"
        assert bound.arguments["ui_port"] == 3000

    def test_dev_inspector_command_parsing_with_new_options(self):
        """Test dev inspector command parsing with new uv options."""
        command, bound, _ = app.parse_args(
            [
                "dev",
                "inspector",
                "server.py",
                "--python",
                "3.10",
                "--project",
                "/workspace",
                "--with-requirements",
                "dev-requirements.txt",
                "--with",
                "pytest",
            ]
        )
        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["python"] == "3.10"
        assert bound.arguments["project"] == Path("/workspace")
        assert bound.arguments["with_requirements"] == Path("dev-requirements.txt")
        assert bound.arguments["with_packages"] == ["pytest"]


class TestRunCommand:
    """Test the run command."""

    def test_run_command_parsing_basic(self):
        """Test basic run command parsing."""
        command, bound, _ = app.parse_args(["run", "server.py"])

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        # Cyclopts only includes non-default values
        assert "transport" not in bound.arguments
        assert "host" not in bound.arguments
        assert "port" not in bound.arguments
        assert "path" not in bound.arguments
        assert "log_level" not in bound.arguments
        assert "no_banner" not in bound.arguments

    def test_run_command_parsing_with_options(self):
        """Test run command parsing with various options."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--transport",
                "http",
                "--host",
                "localhost",
                "--port",
                "8080",
                "--path",
                "/v1/mcp",
                "--log-level",
                "DEBUG",
                "--no-banner",
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["transport"] == "http"
        assert bound.arguments["host"] == "localhost"
        assert bound.arguments["port"] == 8080
        assert bound.arguments["path"] == "/v1/mcp"
        assert bound.arguments["log_level"] == "DEBUG"
        assert bound.arguments["no_banner"] is True

    def test_run_command_parsing_partial_options(self):
        """Test run command parsing with only some options."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--transport",
                "http",
                "--no-banner",
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["transport"] == "http"
        assert bound.arguments["no_banner"] is True
        # Other options should not be present
        assert "host" not in bound.arguments
        assert "port" not in bound.arguments
        assert "log_level" not in bound.arguments
        assert "path" not in bound.arguments

    def test_run_command_parsing_with_new_options(self):
        """Test run command parsing with new uv options."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--python",
                "3.11",
                "--with",
                "pandas",
                "--with",
                "numpy",
                "--project",
                "/path/to/project",
                "--with-requirements",
                "requirements.txt",
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["python"] == "3.11"
        assert bound.arguments["with_packages"] == ["pandas", "numpy"]
        assert bound.arguments["project"] == Path("/path/to/project")
        assert bound.arguments["with_requirements"] == Path("requirements.txt")

    def test_run_command_transport_aliases(self):
        """Test that both 'http' and 'streamable-http' are accepted as valid transport options."""
        # Test with 'http' transport
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--transport",
                "http",
            ]
        )
        assert command is not None
        assert bound.arguments["transport"] == "http"

        # Test with 'streamable-http' transport
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--transport",
                "streamable-http",
            ]
        )
        assert command is not None
        assert bound.arguments["transport"] == "streamable-http"

    def test_run_command_parsing_with_server_args(self):
        """Test run command parsing with server arguments after --."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--",
                "--config",
                "test.json",
                "--debug",
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        # Server args after -- are captured as positional arguments in bound.args
        assert bound.args == ("server.py", "--config", "test.json", "--debug")

    def test_run_command_parsing_with_mixed_args(self):
        """Test run command parsing with both FastMCP options and server args."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--transport",
                "http",
                "--port",
                "8080",
                "--",
                "--server-port",
                "9090",
                "--debug",
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["transport"] == "http"
        assert bound.arguments["port"] == 8080
        # Server args after -- are captured separately from FastMCP options
        assert bound.args == ("server.py", "--server-port", "9090", "--debug")

    def test_run_command_parsing_with_positional_server_args(self):
        """Test run command parsing with positional server arguments."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--",
                "arg1",
                "arg2",
                "--flag",
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        # Positional args and flags after -- are all captured
        assert bound.args == ("server.py", "arg1", "arg2", "--flag")

    def test_run_command_parsing_server_args_require_delimiter(self):
        """Test that server args without -- delimiter are rejected."""
        # Should fail because --config is not a recognized FastMCP option
        with pytest.raises(SystemExit):
            app.parse_args(
                [
                    "run",
                    "server.py",
                    "--config",
                    "test.json",
                ]
            )

    def test_run_command_parsing_project_flag(self):
        """Test run command parsing with --project flag."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--project",
                "./test-env",
            ]
        )
        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["project"] == Path("./test-env")

    def test_run_command_parsing_skip_source_flag(self):
        """Test run command parsing with --skip-source flag."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--skip-source",
            ]
        )
        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["skip_source"] is True

    def test_run_command_parsing_project_and_skip_source(self):
        """Test run command parsing with --project and --skip-source flags."""
        command, bound, _ = app.parse_args(
            [
                "run",
                "server.py",
                "--project",
                "./test-env",
                "--skip-source",
            ]
        )
        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        assert bound.arguments["project"] == Path("./test-env")
        assert bound.arguments["skip_source"] is True

    def test_show_server_banner_setting(self):
        """Test that show_server_banner setting works with environment variable."""
        import os
        from unittest import mock

        from fastmcp.settings import Settings

        # Test default (banner shown)
        settings = Settings()
        assert settings.show_server_banner is True

        # Test with env var set to false (banner hidden)
        with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "false"}):
            settings = Settings()
            assert settings.show_server_banner is False

        # Test CLI precedence logic (simulated)
        with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "true"}):
            settings = Settings()
            # CLI --no-banner flag would override
            cli_no_banner = True
            final = cli_no_banner if cli_no_banner else not settings.show_server_banner
            assert final is True  # Banner suppressed by CLI flag


class TestWindowsSpecific:
    """Test Windows-specific functionality."""

    @patch("subprocess.run")
    def test_get_npx_command_windows_cmd(self, mock_run):
        """Test npx command detection on Windows with npx.cmd."""
        from fastmcp.cli.cli import _get_npx_command

        with patch("sys.platform", "win32"):
            # First call succeeds with npx.cmd
            mock_run.return_value = Mock(returncode=0)

            result = _get_npx_command()

            assert result == "npx.cmd"
            mock_run.assert_called_once_with(
                ["npx.cmd", "--version"],
                check=True,
                capture_output=True,
            )

    @patch("subprocess.run")
    def test_get_npx_command_windows_exe(self, mock_run):
        """Test npx command detection on Windows with npx.exe."""
        from fastmcp.cli.cli import _get_npx_command

        with patch("sys.platform", "win32"):
            # First call fails, second succeeds
            mock_run.side_effect = [
                subprocess.CalledProcessError(1, "npx.cmd"),
                Mock(returncode=0),
            ]

            result = _get_npx_command()

            assert result == "npx.exe"
            assert mock_run.call_count == 2

    @patch("subprocess.run")
    def test_get_npx_command_windows_cmd_missing(self, mock_run):
        """Test npx command detection continues when npx.cmd is missing."""
        from fastmcp.cli.cli import _get_npx_command

        with patch("sys.platform", "win32"):
            # Missing npx.cmd should not abort detection
            mock_run.side_effect = [
                FileNotFoundError("npx.cmd not found"),
                Mock(returncode=0),
            ]

            result = _get_npx_command()

            assert result == "npx.exe"
            assert mock_run.call_count == 2

    @patch("subprocess.run")
    def test_get_npx_command_windows_fallback(self, mock_run):
        """Test npx command detection on Windows with plain npx."""
        from fastmcp.cli.cli import _get_npx_command

        with patch("sys.platform", "win32"):
            # First two calls fail, third succeeds
            mock_run.side_effect = [
                subprocess.CalledProcessError(1, "npx.cmd"),
                subprocess.CalledProcessError(1, "npx.exe"),
                Mock(returncode=0),
            ]

            result = _get_npx_command()

            assert result == "npx"
            assert mock_run.call_count == 3

    @patch("subprocess.run")
    def test_get_npx_command_windows_not_found(self, mock_run):
        """Test npx command detection on Windows when npx is not found."""
        from fastmcp.cli.cli import _get_npx_command

        with patch("sys.platform", "win32"):
            # All calls fail
            mock_run.side_effect = subprocess.CalledProcessError(1, "npx")

            result = _get_npx_command()

            assert result is None
            assert mock_run.call_count == 3

    @patch("subprocess.run")
    def test_get_npx_command_unix(self, mock_run):
        """Test npx command detection on Unix systems."""
        from fastmcp.cli.cli import _get_npx_command

        with patch("sys.platform", "darwin"):
            result = _get_npx_command()

            assert result == "npx"
            mock_run.assert_not_called()

    def test_windows_path_parsing_with_colon(self, tmp_path):
        """Test parsing Windows paths with drive letters and colons."""
        from pathlib import Path

        from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import (
            FileSystemSource,
        )

        # Create a real test file to test the logic
        test_file = tmp_path / "server.py"
        test_file.write_text("# test server")

        # Test normal file parsing (works on all platforms)
        source = FileSystemSource(path=str(test_file))
        assert source.entrypoint is None
        assert Path(source.path).resolve() == test_file.resolve()

        # Test file:object parsing
        source = FileSystemSource(path=f"{test_file}:myapp")
        assert source.entrypoint == "myapp"

        # Test that the file portion resolves correctly when object is specified
        assert Path(source.path).resolve() == test_file.resolve()


class TestInspectCommand:
    """Test the inspect command."""

    def test_inspect_command_parsing_basic(self):
        """Test basic inspect command parsing."""
        command, bound, _ = app.parse_args(["inspect", "server.py"])

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        # Only explicitly set parameters are in bound.arguments
        assert "output" not in bound.arguments

    def test_inspect_command_parsing_with_output(self, tmp_path):
        """Test inspect command parsing with output file."""
        output_file = tmp_path / "output.json"

        command, bound, _ = app.parse_args(
            [
                "inspect",
                "server.py",
                "--output",
                str(output_file),
            ]
        )

        assert command is not None
        assert bound.arguments["server_spec"] == "server.py"
        # Output is parsed as a Path object
        assert bound.arguments["output"] == output_file

    async def test_inspect_command_text_summary(self, tmp_path, capsys):
        """Test inspect command with no format shows text summary."""
        # Create a real server file
        server_file = tmp_path / "test_server.py"
        server_file.write_text("""
import fastmcp

mcp = fastmcp.FastMCP("InspectTestServer", instructions="Test instructions", version="1.0.0")

@mcp.tool
def test_tool(x: int) -> int:
    return x * 2
""")

        # Parse and execute the command without format or output
        command, bound, _ = app.parse_args(
            [
                "inspect",
                str(server_file),
            ]
        )

        await command(**bound.arguments)

        # Check the console output
        captured = capsys.readouterr()
        # Check for the table format output
        assert "InspectTestServer" in captured.out
        assert "Test instructions" in captured.out
        assert "1.0.0" in captured.out
        assert "Tools" in captured.out
        assert "1" in captured.out  # number of tools
        assert "FastMCP" in captured.out
        assert "MCP" in captured.out
        assert "Use --format [fastmcp|mcp] for complete JSON output" in captured.out

    async def test_inspect_command_with_real_server(self, tmp_path):
        """Test inspect command with a real server file."""
        # Create a real server file
        server_file = tmp_path / "test_server.py"
        server_file.write_text("""
import fastmcp

mcp = fastmcp.FastMCP("InspectTestServer")

@mcp.tool
def test_tool(x: int) -> int:
    return x * 2

@mcp.prompt
def test_prompt(name: str) -> str:
    return f"Hello, {name}!"
""")

        output_file = tmp_path / "inspect_output.json"

        # Parse and execute the command with format and output file
        command, bound, _ = app.parse_args(
            [
                "inspect",
                str(server_file),
                "--format",
                "fastmcp",
                "--output",
                str(output_file),
            ]
        )

        await command(**bound.arguments)

        # Verify the output file was created and contains expected content
        assert output_file.exists()
        content = output_file.read_text()

        # Basic checks that the fastmcp format worked
        import json

        data = json.loads(content)
        assert data["server"]["name"] == "InspectTestServer"
        assert len(data["tools"]) == 1
        assert len(data["prompts"]) == 1
