import os
import os.path
import signal
import subprocess
import sys
from io import TextIOWrapper
from textwrap import dedent
from threading import Thread
from typing import Callable

import pytest

tests_dir = os.path.dirname(os.path.abspath(__file__))

python_exc = sys.executable or "python3"


E2E_STEP_TIMEOUT_SEC = 30


E2E_STEPS = (
    {
        "command": (
            "dvcx",
            "find",
            "--anon",
            "--name",
            "cat.1.*",
            "gcs://dvcx-datalakes/dogs-and-cats/",
        ),
        "expected": dedent(
            """
            gcs://dvcx-datalakes/dogs-and-cats/cat.1.jpg
            gcs://dvcx-datalakes/dogs-and-cats/cat.1.json
            """
        ),
        "listing": True,
    },
    {
        "command": (
            python_exc,
            os.path.join(tests_dir, "scripts", "name_len_normal.py"),
        ),
        "expected": dedent(
            """
            [('cat.1.jpg', 9), ('cat.1.json', -1)]
            """
        ),
    },
    {
        "command": (
            python_exc,
            os.path.join(tests_dir, "scripts", "name_len_slow.py"),
        ),
        "interrupt_after": "UDF Processing Started",
        "expected_in_stderr": "KeyboardInterrupt",
        "expected_not_in_stderr": "semaphore",
    },
    {
        "command": ("dvcx", "gc"),
        "expected": "Nothing to clean up.\n",
    },
)


def watch_process_thread(
    stream: TextIOWrapper, output_lines: list[str], watch_value: str, callback: Callable
) -> None:
    """
    Watches either the stdout or stderr stream from a given process,
    reads the output into output_lines, and watches for the given watch_value,
    then calls callback once found.
    """
    while (line := stream.readline()) != "":
        line = line.strip()
        output_lines.append(line)
        if watch_value in line:
            callback()


def communicate_and_interrupt_process(
    process: subprocess.Popen, interrupt_after: str
) -> tuple[str, str]:
    def interrupt_step() -> None:
        if sys.platform == "win32":
            # Windows has a different mechanism of sending a Ctrl-C event.
            process.send_signal(signal.CTRL_C_EVENT)
        else:
            process.send_signal(signal.SIGINT)

    stdout_lines: list[str] = []
    stderr_lines: list[str] = []
    watch_threads = (
        Thread(
            target=watch_process_thread,
            name="Test-Query-E2E-Interrupt-stdout",
            daemon=True,
            args=[process.stdout, stdout_lines, interrupt_after, interrupt_step],
        ),
        Thread(
            target=watch_process_thread,
            name="Test-Query-E2E-Interrupt-stderr",
            daemon=True,
            args=[process.stderr, stderr_lines, interrupt_after, interrupt_step],
        ),
    )
    for t in watch_threads:
        t.start()
    process.wait(timeout=E2E_STEP_TIMEOUT_SEC)
    return "\n".join(stdout_lines), "\n".join(stderr_lines)


def run_step(step):
    """Run an end-to-end query test step with a command and expected output."""
    command = step["command"]
    # Note that a process.returncode of -2 is the same as the shell returncode of 130
    # (canceled by KeyboardInterrupt)
    interrupt_exit_code = -2
    if sys.platform == "win32":
        # Windows has a different mechanism of creating a process group.
        popen_args = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
        # This is STATUS_CONTROL_C_EXIT which is equivalent to 0xC000013A
        interrupt_exit_code = 3221225786
    else:
        popen_args = {"start_new_session": True}
    process = subprocess.Popen(
        command,
        shell=False,  # noqa: S603
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        encoding="utf-8",
        **popen_args,
    )
    interrupt_after = step.get("interrupt_after")
    if interrupt_after:
        stdout, stderr = communicate_and_interrupt_process(process, interrupt_after)
    else:
        stdout, stderr = process.communicate(timeout=E2E_STEP_TIMEOUT_SEC)

    if interrupt_after:
        if process.returncode not in (interrupt_exit_code, 1):
            print(f"Process stdout: {stdout}")
            print(f"Process stderr: {stderr}")
            raise RuntimeError(
                "Query script failed to interrupt correctly: "
                f"{process.returncode} Command: {command}"
            )
    elif process.returncode != 0:
        print(f"Process stdout: {stdout}")
        print(f"Process stderr: {stderr}")
        raise RuntimeError(
            "Query script failed with exit code: "
            f"{process.returncode} Command: {command}"
        )

    if step.get("sort_expected_lines"):
        assert sorted(stdout.split("\n")) == sorted(
            step["expected"].lstrip("\n").split("\n")
        )
    elif step.get("expected_in_stderr"):
        assert step["expected_in_stderr"] in stderr
        if step.get("expected_not_in_stderr"):
            assert step["expected_not_in_stderr"] not in stderr
    else:
        assert stdout == step["expected"].lstrip("\n")

    if step.get("listing"):
        assert "Listing" in stderr
    else:
        assert "Listing" not in stderr


@pytest.mark.e2e
def test_query_e2e(tmp_dir):
    """End-to-end CLI Query Test"""
    for step in E2E_STEPS:
        run_step(step)
