"""
Test module for Responder CLI functionality.

This module tests the following CLI commands:
- responder --version: Version display
- responder build: Build command execution
- responder run: Server execution

Requirements:
- The `docopt-ng` package must be installed
- Example application must be present at `examples/helloworld.py`
- This file should implement a basic HTTP server with a "/hello" endpoint
  that returns "hello, world!" as response
"""

import json
import os
import subprocess
import time
import typing as t
from pathlib import Path
from urllib.request import urlopen

import pytest
from _pytest.capture import CaptureFixture

from responder.__version__ import __version__
from responder.util.cmd import ResponderProgram, ResponderServer
from tests.util import random_port, wait_server_tcp

# Skip test if optional CLI dependency is not installed.
pytest.importorskip("docopt", reason="docopt-ng package not installed")


# Pseudo-wait for server idleness
SERVER_IDLE_WAIT = float(os.getenv("RESPONDER_SERVER_IDLE_WAIT", "0.25"))

# Maximum time to wait for server startup or teardown (adjust for slower systems)
SERVER_TIMEOUT = float(os.getenv("RESPONDER_SERVER_TIMEOUT", "5"))

# Maximum time to wait for HTTP requests (adjust for slower networks)
REQUEST_TIMEOUT = float(os.getenv("RESPONDER_REQUEST_TIMEOUT", "5"))

# Endpoint to use for `responder run`.
HELLO_ENDPOINT = "/hello"


def test_cli_version(capfd):
    """
    Verify that `responder --version` works as expected.
    """
    try:
        # Suppress security checks for subprocess calls in tests.
        # S603: subprocess call - safe as we use fixed command
        # S607: start process with partial path - safe as we use installed package
        subprocess.check_call(["responder", "--version"])  # noqa: S603, S607
    except subprocess.CalledProcessError as ex:
        pytest.fail(
            f"responder --version failed with exit code {ex.returncode}. Error: {ex}"
        )

    stdout = capfd.readouterr().out.strip()
    assert stdout == __version__


def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]:
    """
    Execute responder build command and capture its output.

    Args:
        path: Directory containing package.json
        capfd: Pytest fixture for capturing output

    Returns:
        tuple: (stdout, stderr) containing the captured output
    """

    ResponderProgram.build(path=path)
    output = capfd.readouterr()

    stdout = output.out.strip()
    stderr = output.err.strip()

    return stdout, stderr


def test_cli_build_success(capfd, tmp_path):
    """
    Verify that `responder build` works as expected.
    """

    # Temporary surrogate `package.json` file.
    package_json = {"scripts": {"build": "echo Hotzenplotz"}}
    package_json_file = tmp_path / "package.json"
    package_json_file.write_text(json.dumps(package_json))

    # Invoke `responder build`.
    stdout, stderr = responder_build(tmp_path, capfd)
    assert "Hotzenplotz" in stdout


def test_cli_build_missing_package_json(capfd, tmp_path):
    """
    Verify `responder build`, while `package.json` file is missing.
    """

    # Invoke `responder build`.
    stdout, stderr = responder_build(tmp_path, capfd)
    assert "Invalid target directory or missing package.json" in stderr


@pytest.mark.parametrize(
    "invalid_content,npm_error,expected_error",
    [
        (
            "foobar",
            "code EJSONPARSE",
            ["is not valid JSON", "Failed to parse JSON data", "EJSONPARSE"],
        ),
        ("{", "code EJSONPARSE", ["Unexpected end of JSON", "EJSONPARSE"]),
        ('{"scripts": }', "code EJSONPARSE", ["Unexpected token", "EJSONPARSE"]),
        (
            '{"scripts": null}',
            "error",
            [
                "Cannot convert undefined or null",
                "scripts.build",
                "Missing script",
                "null",
            ],
        ),
        (
            '{"scripts": {"build": null}}',
            "Missing script",
            ['"build"', "missing script", "build"],
        ),
        (
            '{"scripts": {"build": 123}}',
            "Missing script",
            ['"build"', "missing script", "build"],
        ),
    ],
    ids=[
        "invalid_json_content",
        "incomplete_json",
        "syntax_error",
        "null_scripts",
        "missing_script_null",
        "missing_script_number",
    ],
)
def test_cli_build_invalid_package_json(
    capfd, tmp_path, invalid_content, npm_error, expected_error
):
    """
    Verify `responder build` using an invalid `package.json` file.
    """

    # Temporary surrogate `package.json` file.
    package_json_file = tmp_path / "package.json"
    package_json_file.write_text(invalid_content)

    # Invoke `responder build`.
    stdout, stderr = responder_build(tmp_path, capfd)
    assert npm_error.lower() in stderr.lower()
    if isinstance(expected_error, str):
        expected_error = [expected_error]
    assert any(item.lower() in stderr.lower() for item in expected_error)


sfa_services_valid = [
    str(Path("examples") / "helloworld.py"),
    "https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py",
]


# The test is marked as flaky due to potential race conditions in server startup
# and port availability. Known error codes by platform:
# - macOS:   [Errno 61] Connection refused (Failed to establish a new connection)
# - Linux:   [Errno 111] Connection refused (Failed to establish a new connection)
# - Windows: [WinError 10061] No connection could be made because target machine
#            actively refused it
@pytest.mark.flaky(reruns=3, reruns_delay=2, only_rerun=["TimeoutError"])
@pytest.mark.parametrize("target", sfa_services_valid, ids=sfa_services_valid)
def test_cli_run(capfd, target):
    """
    Verify that `responder run` works as expected.
    """

    # Start a Responder service instance in the background, using its CLI.
    # Make it terminate itself after serving one HTTP request.
    server = ResponderServer(target=str(target), port=random_port(), limit_max_requests=1)
    try:
        # Start server and wait until it responds on TCP.
        server.start()
        wait_server_tcp(server.port)

        # Submit a single probing HTTP request that also will terminate the server.
        with urlopen(  # noqa: S310
            f"http://127.0.0.1:{server.port}{HELLO_ENDPOINT}",
            timeout=REQUEST_TIMEOUT,
        ) as response:
            assert "hello, world!" == response.read().decode()
    finally:
        server.join(timeout=SERVER_TIMEOUT)

    # Capture process output.
    time.sleep(SERVER_IDLE_WAIT)
    output = capfd.readouterr()

    stdout = output.out.strip()
    assert f'"GET {HELLO_ENDPOINT} HTTP/1.1" 200 OK' in stdout

    stderr = output.err.strip()

    # Define expected lifecycle messages in order.
    lifecycle_messages = [
        # Startup phase
        "Started server process",
        "Waiting for application startup",
        "Application startup complete",
        "Uvicorn running",
        # Shutdown phase
        "Shutting down",
        "Waiting for application shutdown",
        "Application shutdown complete",
        "Finished server process",
    ]

    # Verify messages appear in expected order.
    last_pos = -1
    for msg in lifecycle_messages:
        pos = stderr.find(msg)
        assert pos > last_pos, f"Expected '{msg}' to appear after previous message"
        last_pos = pos
