import json
import re
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path
from typing import *  # type: ignore

import pytest

import rio.docs

CODE_BLOCK_PATTERN = re.compile(r"```(.*?)```", re.DOTALL)


all_documented_objects = list(rio.docs.find_documented_objects())
all_documented_objects.sort(key=lambda obj: obj.__name__)


def ruff(*args: str | Path) -> subprocess.CompletedProcess:
    return subprocess.run(
        [sys.executable, "-m", "ruff", *map(str, args)],
        check=True,
        capture_output=True,
        text=True,
    )


def get_code_blocks(obj: type | Callable) -> list[str]:
    """
    Returns a list of all code blocks in the docstring of a component.
    """
    docstring = obj.__doc__

    # No docs?
    if not docstring:
        return []

    docstring = textwrap.dedent(docstring)

    # Find any contained code blocks
    result: list[str] = []
    for match in CODE_BLOCK_PATTERN.finditer(docstring):
        block: str = match.group(1)

        # Split into language and source
        linebreak = block.find("\n")
        assert linebreak != -1
        language = block[:linebreak]
        block = block[linebreak + 1 :]

        # Make sure a language is specified
        assert language, "The code block has no language specified"

        result.append(block)

    return result


def ruff_format(source_code: str) -> str:
    # Write the source code to a temporary file
    temp_file_path = Path(tempfile.gettempdir()) / "rio test suite tempfile.py"

    temp_file_path.write_text(source_code, encoding="utf8")

    # Run ruff to format the source code in the temporary file
    ruff("format", temp_file_path)

    # Read the formatted source code
    return temp_file_path.read_text(encoding="utf8")


def ruff_check(source_code: str) -> list[str]:
    """
    Checks the given source code using `ruff`. Returns any encountered problems.
    """
    # Dump the source to a file, and implicitly define/import some stuff
    temp_file_path = Path(tempfile.gettempdir()) / "rio test suite tempfile.py"

    temp_file_path.write_text(
        f"""
import pathlib
import rio

# Importing `Path` directly causes ruff to complain about a redefinition
Path = pathlib.Path
self = rio.Spacer()

{source_code}
""",
        encoding="utf8",
    )

    # Run ruff to format the source code in the temporary file
    proc = ruff(
        "check",
        temp_file_path,
        "--ignore=E402",  # Caused by the injected imports
        "--output-format=json",
    )

    output = json.loads(proc.stdout)
    assert isinstance(output, list), output

    # Parse the output
    result: list[str] = []

    for entry in output:
        result.append(entry["message"])

    return result


@pytest.mark.parametrize("obj", all_documented_objects)
def test_code_block_is_formatted(obj: type | Callable) -> None:
    # Make sure all code blocks are formatted according to ruff
    for source in get_code_blocks(obj):
        formatted_source = ruff_format(source)

        # Ruff often inserts 2 empty lines between definitions, but that's
        # really not necessary in docstrings. Collapse them to a single empty
        # line.
        source = source.replace("\n\n\n", "\n\n")
        formatted_source = formatted_source.replace("\n\n\n", "\n\n")

        assert formatted_source == source


@pytest.mark.parametrize("obj", all_documented_objects)
def test_analyze_code_block(obj: type | Callable) -> None:
    # A lot of snippets are missing context, so it's only natural that ruff will
    # find issues with the code. There isn't really anything we can do about it,
    # so we'll just skip those object.
    if obj in (rio.App, rio.Color, rio.UserSettings):
        pytest.xfail()

    # Make sure ruff is happy with all code blocks
    for source in get_code_blocks(obj):
        errors = ruff_check(source)
        assert not errors, errors
