"""Pytest plugin that turns README code blocks into executable tests."""

from __future__ import annotations

import io
import traceback
from contextlib import redirect_stdout
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Sequence

import pytest
from _pytest.warning_types import PytestWarning

PLUGIN_NAME = "swarmauri_tests_readme_examples"
DEFAULT_FILES: Sequence[str] = ("README.md",)
DEFAULT_LANGUAGES: Sequence[str] = ("python", "py")
DEFAULT_MODE = "parameterized"
DEFAULT_SKIP_MARKERS: Sequence[str] = ("# pytest: skip-example", "# no-exec")
UNTAGGED_SENTINEL = "untagged"
LANGUAGE_ALIASES = {"py": "python", "pycon": "pycon"}


@dataclass
class ReadmeExample:
    path: Path
    index: int
    line: int
    language: str
    heading: str | None
    code: str

    @property
    def display_name(self) -> str:
        suffix = f"::{self.heading}" if self.heading else ""
        return f"{self.path.name}{suffix}::block-{self.index}"


def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("readme")
    group.addoption(
        "--readme-mode",
        action="store",
        default=None,
        choices=["parameterized", "aggregate"],
        help="Run README examples as individual tests or a single aggregate.",
    )
    group.addoption(
        "--readme-files",
        action="store",
        default=None,
        help="Comma or newline separated Markdown files to scan for examples.",
    )
    group.addoption(
        "--readme-languages",
        action="store",
        default=None,
        help="Comma or newline separated code block languages to execute.",
    )
    group.addoption(
        "--readme-skip-markers",
        action="store",
        default=None,
        help="Markers that skip the first non-empty line of a block.",
    )

    parser.addini(
        "readme_mode",
        "Execution mode for README examples (parameterized or aggregate).",
        default=DEFAULT_MODE,
    )
    parser.addini(
        "readme_files",
        "Markdown files that contain executable README examples.",
        type="linelist",
        default=list(DEFAULT_FILES),
    )
    parser.addini(
        "readme_languages",
        "Accepted code block languages (one per line).",
        type="linelist",
        default=list(DEFAULT_LANGUAGES),
    )
    parser.addini(
        "readme_skip_markers",
        "Markers that skip execution when found on the first non-empty line.",
        type="linelist",
        default=list(DEFAULT_SKIP_MARKERS),
    )


def pytest_configure(config: pytest.Config) -> None:
    config.addinivalue_line(
        "markers",
        "readme_example: autogenerated README code block test",
    )

    files = _resolve_files(config)
    if not files:
        config._readme_examples = []
        config._readme_mode = DEFAULT_MODE
        return

    languages, allow_untagged = _resolve_languages(config)
    skip_markers = _resolve_skip_markers(config)

    examples: list[ReadmeExample] = []
    for path in files:
        examples.extend(
            _extract_examples(path, languages, skip_markers, allow_untagged)
        )

    config._readme_examples = examples
    config._readme_mode = _resolve_mode(config)
    config._readme_allow_untagged = allow_untagged
    config._readme_languages = languages


def pytest_collection_modifyitems(
    session: pytest.Session, config: pytest.Config, items: list[pytest.Item]
) -> None:
    if getattr(config, "_readme_items_added", False):
        return

    examples: list[ReadmeExample] = getattr(config, "_readme_examples", [])
    if not examples:
        config._readme_items_added = True
        return

    config._readme_items_added = True
    mode: str = getattr(config, "_readme_mode", DEFAULT_MODE)

    if mode == "parameterized":
        for example in examples:
            item = ReadmeExampleItem.from_parent(
                session,
                name=f"{PLUGIN_NAME}:readme::{example.display_name}",
                example=example,
            )
            items.append(item)
    else:
        item = ReadmeAggregateItem.from_parent(
            session,
            name=f"{PLUGIN_NAME}:readme-aggregate",
            examples=examples,
        )
        items.append(item)


class ReadmeExampleItem(pytest.Item):
    def __init__(self, name: str, parent: pytest.Collector, example: ReadmeExample):
        super().__init__(name, parent)
        self.example = example
        self.path = example.path

    def runtest(self) -> None:
        failure = _execute_example(self.example)
        if failure:
            pytest.fail(failure, pytrace=False)

    def reportinfo(self) -> tuple[Path, int, str]:
        return self.example.path, self.example.line, self.example.display_name


class ReadmeAggregateItem(pytest.Item):
    def __init__(
        self, name: str, parent: pytest.Collector, examples: Sequence[ReadmeExample]
    ):
        super().__init__(name, parent)
        self.examples = list(examples)

    def runtest(self) -> None:
        failures = []
        for example in self.examples:
            failure = _execute_example(example)
            if failure:
                failures.append(failure)
        if failures:
            pytest.fail("\n".join(failures), pytrace=False)

    def reportinfo(self) -> tuple[str, None, str]:
        return "README examples", None, "aggregate README check"


def _resolve_mode(config: pytest.Config) -> str:
    opt = config.getoption("--readme-mode")
    if opt:
        return opt
    ini_mode = str(config.getini("readme_mode") or "").strip().lower()
    if ini_mode in {"parameterized", "aggregate"}:
        return ini_mode
    return DEFAULT_MODE


def _resolve_files(config: pytest.Config) -> List[Path]:
    cli_values = _split_values(config.getoption("--readme-files"))
    ini_values = _split_values(config.getini("readme_files"))
    candidates = cli_values or ini_values or list(DEFAULT_FILES)

    resolved: list[Path] = []
    missing: list[str] = []
    root = Path(config.rootpath)
    for entry in candidates:
        path = Path(entry)
        if not path.is_absolute():
            path = root / path
        if path.exists():
            resolved.append(path)
        else:
            missing.append(str(path))
    if missing:
        config.issue_config_time_warning(
            PytestWarning(
                "readme examples plugin: missing files -> " + ", ".join(missing)
            ),
            stacklevel=2,
        )
    return resolved


def _resolve_languages(config: pytest.Config) -> tuple[set[str], bool]:
    cli = _split_values(config.getoption("--readme-languages"))
    ini = _split_values(config.getini("readme_languages"))
    values = cli or ini or list(DEFAULT_LANGUAGES)
    allow_untagged = False
    languages: set[str] = set()
    for value in values:
        lowered = value.lower()
        if not lowered:
            continue
        if lowered in {UNTAGGED_SENTINEL, "auto"}:
            allow_untagged = True
            continue
        languages.add(lowered)
    if not languages:
        languages.update(DEFAULT_LANGUAGES)
    return languages, allow_untagged


def _resolve_skip_markers(config: pytest.Config) -> set[str]:
    cli = _split_values(config.getoption("--readme-skip-markers"))
    ini = _split_values(config.getini("readme_skip_markers"))
    values = cli or ini or list(DEFAULT_SKIP_MARKERS)
    return {value.strip() for value in values if value.strip()}


def _split_values(raw: object) -> list[str]:
    if not raw:
        return []
    if isinstance(raw, (list, tuple)):
        values: list[str] = []
        for entry in raw:
            values.extend(_split_values(entry))
        return values
    text = str(raw).replace("\r", "\n")
    values = []
    for line in text.splitlines():
        if not line.strip():
            continue
        for chunk in line.split(","):
            chunk = chunk.strip()
            if chunk:
                values.append(chunk)
    return values


def _extract_examples(
    path: Path, languages: set[str], skip_markers: set[str], allow_untagged: bool
) -> list[ReadmeExample]:
    text = path.read_text(encoding="utf-8")
    examples: list[ReadmeExample] = []
    for index, (lang, code, line, heading) in enumerate(
        _iter_code_blocks(text),
        start=1,
    ):
        raw_language = (lang or "").strip().lower()
        canonical = raw_language
        exec_language = LANGUAGE_ALIASES.get(canonical, canonical)
        if not canonical:
            if not allow_untagged:
                continue
            canonical = UNTAGGED_SENTINEL
            exec_language = "python"
        if canonical not in languages and exec_language not in languages:
            if not (canonical == UNTAGGED_SENTINEL and allow_untagged):
                continue
        normalized = _normalize_code(exec_language, code)
        if _should_skip(normalized, skip_markers):
            continue
        examples.append(
            ReadmeExample(
                path=path,
                index=index,
                line=line,
                language=exec_language,
                heading=heading,
                code=normalized,
            )
        )
    return examples


def _iter_code_blocks(text: str) -> Iterable[tuple[str, str, int, str | None]]:
    lines = text.splitlines()
    heading: str | None = None
    i = 0
    total = len(lines)
    while i < total:
        raw = lines[i]
        stripped = raw.strip()
        if stripped.startswith("#"):
            heading = stripped.lstrip("#").strip() or None
            i += 1
            continue
        if stripped.startswith("```"):
            lang = stripped[3:].strip()
            start_line = i + 2
            i += 1
            block_lines: list[str] = []
            while i < total and not lines[i].strip().startswith("```"):
                block_lines.append(lines[i])
                i += 1
            code = "\n".join(block_lines)
            while i < total and not lines[i].strip().startswith("```"):
                i += 1
            if i < total:
                i += 1
            yield lang, code, start_line, heading
            continue
        i += 1


def _normalize_code(language: str, code: str) -> str:
    if language != "pycon":
        return code
    cleaned: list[str] = []
    for line in code.splitlines():
        if line.startswith(">>> ") or line.startswith("... "):
            cleaned.append(line[4:])
        elif line.startswith(">>>") or line.startswith("..."):
            cleaned.append(line[3:])
        else:
            cleaned.append(line)
    return "\n".join(cleaned)


def _should_skip(code: str, markers: set[str]) -> bool:
    for line in code.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        for marker in markers:
            if marker in stripped:
                return True
        break
    return False


def _execute_example(example: ReadmeExample) -> str | None:
    namespace: dict[str, object] = {"__name__": "__main__"}
    stdout = io.StringIO()
    code_object = compile(
        example.code,
        f"{example.path}#example-{example.index}",
        "exec",
    )
    try:
        with redirect_stdout(stdout):
            exec(code_object, namespace, namespace)
    except Exception as exc:  # pragma: no cover - verified via plugin tests
        summary = "".join(traceback.format_exception_only(exc.__class__, exc)).strip()
        return (
            f"{example.path}:{example.line} ({example.display_name}) raised {summary}"
        )
    return None


__all__ = [
    "ReadmeExample",
]
