"""Project script helpers for qpip."""

from __future__ import annotations

import json
import os
import re
import shlex
import subprocess
import sys
from difflib import get_close_matches
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Optional, Sequence, TextIO

from .project_venv import (
    build_env_execution_env,
    find_project_root,
    get_env_python_path,
    project_env_exists,
)

DEFAULT_QPIP_SCRIPTS = {
    "test": "python -m unittest discover -s tests -v",
}

TABLE_HEADER_PATTERN = re.compile(r"^\[(?P<name>[^\]]+)\]\s*$")
BARE_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
SHELL_OPERATOR_TOKENS = {"|", "||", "&", "&&", ";", "<", ">", ">>", "<<", "`"}
ENTRYPOINT_RUNNER_CODE = """
import importlib
import sys

script_name = sys.argv[1]
entrypoint = sys.argv[2]
extra_args = sys.argv[3:]
module_name, separator, qualname = entrypoint.partition(':')
if not separator or not module_name or not qualname:
    raise SystemExit(f'Invalid entry point: {entrypoint}')
obj = importlib.import_module(module_name)
for part in qualname.split('.'):
    obj = getattr(obj, part)
sys.argv = [script_name, *extra_args]
result = obj()
if isinstance(result, int):
    raise SystemExit(result)
raise SystemExit(0 if result is None else 0)
""".strip()

QPIP_SCRIPTS_TABLE = "tool.qpip.scripts"
PROJECT_SCRIPTS_TABLE = "project.scripts"
PROJECT_GUI_SCRIPTS_TABLE = "project.gui-scripts"
POETRY_SCRIPTS_TABLE = "tool.poetry.scripts"


class RunUsageError(ValueError):
    """Raised when qpip run usage is invalid."""


@dataclass(frozen=True)
class ScriptDefinition:
    """Definition of a script loaded from pyproject.toml."""

    name: str
    command: str | tuple[str, ...]
    source: str
    kind: str


def toml_string(value: str) -> str:
    """Render a Python string as a TOML basic string."""
    return json.dumps(value, ensure_ascii=False)


def format_toml_key(key: str) -> str:
    """Render a TOML key, quoting it when necessary."""
    return key if BARE_KEY_PATTERN.match(key) else toml_string(key)


def toml_string_array(values: Sequence[str]) -> str:
    """Render a list of strings as a TOML string array."""
    return "[" + ", ".join(toml_string(value) for value in values) + "]"


def build_scripts_section(scripts: Mapping[str, str | Sequence[str]]) -> list[str]:
    """Build the qpip scripts section for pyproject.toml."""
    lines = [f"[{QPIP_SCRIPTS_TABLE}]"]
    for name, command in scripts.items():
        rendered = (
            toml_string(command)
            if isinstance(command, str)
            else toml_string_array(list(command))
        )
        lines.append(f"{format_toml_key(name)} = {rendered}")
    return lines


def parse_toml_string(value: str) -> str:
    """Parse a one-line TOML string value."""
    raw = value.lstrip()
    if not raw:
        raise RunUsageError("scripts 配置值不能为空")

    if raw[0] == '"':
        escaped = False
        for index in range(1, len(raw)):
            char = raw[index]
            if escaped:
                escaped = False
                continue
            if char == "\\":
                escaped = True
                continue
            if char == '"':
                remainder = raw[index + 1 :].strip()
                if remainder and not remainder.startswith("#"):
                    raise RunUsageError("scripts 配置格式无效")
                return json.loads(raw[: index + 1])
        raise RunUsageError("scripts 字符串缺少结束引号")

    if raw[0] == "'":
        index = raw.find("'", 1)
        if index == -1:
            raise RunUsageError("scripts 字符串缺少结束引号")
        remainder = raw[index + 1 :].strip()
        if remainder and not remainder.startswith("#"):
            raise RunUsageError("scripts 配置格式无效")
        return raw[1:index]

    raise RunUsageError("scripts 仅支持单行字符串命令")


def split_toml_array_items(raw: str) -> list[str]:
    """Split a one-line TOML array into raw item strings."""
    items: list[str] = []
    buffer: list[str] = []
    in_double = False
    in_single = False
    escaped = False

    for char in raw:
        if escaped:
            buffer.append(char)
            escaped = False
            continue
        if in_double:
            buffer.append(char)
            if char == "\\":
                escaped = True
            elif char == '"':
                in_double = False
            continue
        if in_single:
            buffer.append(char)
            if char == "'":
                in_single = False
            continue
        if char == '"':
            in_double = True
            buffer.append(char)
            continue
        if char == "'":
            in_single = True
            buffer.append(char)
            continue
        if char == ",":
            items.append("".join(buffer).strip())
            buffer = []
            continue
        buffer.append(char)

    if in_double or in_single or escaped:
        raise RunUsageError("scripts 数组字符串缺少结束引号")

    trailing = "".join(buffer).strip()
    if trailing:
        items.append(trailing)
    return items


def parse_toml_string_array(value: str) -> tuple[str, ...]:
    """Parse a one-line TOML array of strings."""
    raw = value.lstrip()
    if not raw.startswith("["):
        raise RunUsageError("scripts 数组格式无效")

    depth = 0
    end_index = -1
    in_double = False
    in_single = False
    escaped = False

    for index, char in enumerate(raw):
        if escaped:
            escaped = False
            continue
        if in_double:
            if char == "\\":
                escaped = True
            elif char == '"':
                in_double = False
            continue
        if in_single:
            if char == "'":
                in_single = False
            continue
        if char == '"':
            in_double = True
            continue
        if char == "'":
            in_single = True
            continue
        if char == "[":
            depth += 1
            continue
        if char == "]":
            depth -= 1
            if depth == 0:
                end_index = index
                break

    if end_index == -1:
        raise RunUsageError("scripts 数组缺少结束方括号")

    remainder = raw[end_index + 1 :].strip()
    if remainder and not remainder.startswith("#"):
        raise RunUsageError("scripts 配置格式无效")

    inner = raw[1:end_index].strip()
    if not inner:
        raise RunUsageError("scripts 数组不能为空")

    items = split_toml_array_items(inner)
    if not items:
        raise RunUsageError("scripts 数组不能为空")
    try:
        return tuple(parse_toml_string(item) for item in items)
    except RunUsageError as exc:
        raise RunUsageError("scripts 数组仅支持字符串元素") from exc


def parse_toml_key(value: str) -> str:
    """Parse a TOML key used in a script table."""
    raw = value.strip()
    if not raw:
        raise RunUsageError("scripts 配置键不能为空")
    if raw[0] in {'"', "'"}:
        return parse_toml_string(raw)
    return raw


def find_pyproject_path(cwd: Path) -> Path:
    """Return the pyproject.toml path in the working directory."""
    project_root = find_project_root(cwd, markers=("pyproject.toml",))
    if project_root is None:
        raise RunUsageError("当前目录及其上级目录未找到 pyproject.toml")
    pyproject_path = project_root / "pyproject.toml"
    if not pyproject_path.exists():
        raise RunUsageError("当前目录未找到 pyproject.toml")
    return pyproject_path


def load_string_table(pyproject_path: Path, table_name: str) -> dict[str, str]:
    """Load a simple string table from pyproject.toml."""
    in_target_section = False
    found_target_section = False
    items: dict[str, str] = {}

    for line in pyproject_path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        table_match = TABLE_HEADER_PATTERN.match(stripped)
        if table_match:
            current_table_name = table_match.group("name").strip()
            if in_target_section:
                break
            if current_table_name == table_name:
                found_target_section = True
                in_target_section = True
            continue

        if not in_target_section:
            continue

        if not stripped or stripped.startswith("#"):
            continue

        key_part, separator, value_part = line.partition("=")
        if not separator:
            raise RunUsageError(f"[{table_name}] 中存在无效行")

        items[parse_toml_key(key_part)] = parse_toml_string(value_part)

    return items if found_target_section else {}


def load_qpip_scripts_table(pyproject_path: Path) -> dict[str, ScriptDefinition]:
    """Load qpip scripts supporting string and string-array values."""
    in_target_section = False
    found_target_section = False
    items: dict[str, ScriptDefinition] = {}

    for line in pyproject_path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        table_match = TABLE_HEADER_PATTERN.match(stripped)
        if table_match:
            current_table_name = table_match.group("name").strip()
            if in_target_section:
                break
            if current_table_name == QPIP_SCRIPTS_TABLE:
                found_target_section = True
                in_target_section = True
            continue

        if not in_target_section:
            continue

        if not stripped or stripped.startswith("#"):
            continue

        key_part, separator, value_part = line.partition("=")
        if not separator:
            raise RunUsageError(f"[{QPIP_SCRIPTS_TABLE}] 中存在无效行")

        name = parse_toml_key(key_part)
        raw_value = value_part.lstrip()
        if raw_value.startswith("["):
            command = parse_toml_string_array(value_part)
            items[name] = ScriptDefinition(
                name=name,
                command=command,
                source=QPIP_SCRIPTS_TABLE,
                kind="argv",
            )
            continue

        command = parse_toml_string(value_part)
        items[name] = ScriptDefinition(
            name=name,
            command=command,
            source=QPIP_SCRIPTS_TABLE,
            kind="shell",
        )

    return items if found_target_section else {}


def load_project_scripts(pyproject_path: Path) -> dict[str, ScriptDefinition]:
    """Load compatible project scripts from pyproject.toml."""
    qpip_scripts = load_qpip_scripts_table(pyproject_path)
    if qpip_scripts:
        return qpip_scripts

    fallback_tables = (
        (PROJECT_SCRIPTS_TABLE, "entrypoint"),
        (PROJECT_GUI_SCRIPTS_TABLE, "entrypoint"),
        (POETRY_SCRIPTS_TABLE, "entrypoint"),
    )
    scripts: dict[str, ScriptDefinition] = {}

    for table_name, kind in fallback_tables:
        for name, command in load_string_table(pyproject_path, table_name).items():
            scripts.setdefault(
                name,
                ScriptDefinition(name=name, command=command, source=table_name, kind=kind),
            )

    return scripts


def parse_run_args(args: Sequence[str]) -> tuple[Optional[str], tuple[str, ...]]:
    """Parse qpip run arguments."""
    if not args or args[0] != "run":
        raise RunUsageError("run 命令格式无效")

    trailing_args = list(args[1:])
    if "--" in trailing_args:
        separator_index = trailing_args.index("--")
        script_tokens = trailing_args[:separator_index]
        extra_args = tuple(trailing_args[separator_index + 1 :])
    else:
        script_tokens = trailing_args
        extra_args = ()

    if not script_tokens:
        return None, extra_args

    script_name = script_tokens[0]
    if len(script_tokens) > 1:
        raise RunUsageError("传递脚本参数请使用 `qpip run <script> -- <args>`")

    return script_name, extra_args


def format_shell_args(args: Sequence[str]) -> str:
    """Format extra shell arguments for display and execution."""
    if not args:
        return ""
    if os.name == "nt":
        return subprocess.list2cmdline(list(args))
    return shlex.join(list(args))


def build_run_display_command(definition: ScriptDefinition, extra_args: Sequence[str]) -> str:
    """Build a human-readable command preview for qpip run."""
    rendered_args = format_shell_args(extra_args)
    base = (
        definition.command
        if isinstance(definition.command, str)
        else format_subprocess_command(definition.command)
    )
    if not rendered_args:
        return base
    return f"{base} {rendered_args}"


def format_subprocess_command(command: Sequence[str]) -> str:
    """Render a subprocess command in a shell-friendly format."""
    if os.name == "nt":
        return subprocess.list2cmdline(list(command))
    return shlex.join(list(command))


def command_requires_shell(command: str) -> bool:
    """Return whether a script command should be executed through a shell."""
    if "\n" in command:
        return True
    if "$(" in command or "${" in command:
        return True

    try:
        lexer = shlex.shlex(command, posix=True, punctuation_chars="|&;<>")
        lexer.whitespace_split = True
        tokens = list(lexer)
    except ValueError:
        return True

    return any(token in SHELL_OPERATOR_TOKENS for token in tokens)


def build_shell_script_command(
    definition: ScriptDefinition,
    extra_args: Sequence[str],
) -> tuple[Sequence[str] | str, bool]:
    """Build the subprocess payload for qpip shell scripts.

    Simple commands are executed directly to reduce shell-dependent behavior.
    Commands that rely on shell syntax fall back to shell execution.
    """
    if not isinstance(definition.command, str):
        return [*definition.command, *extra_args], False
    if command_requires_shell(definition.command):
        return build_run_display_command(definition, extra_args), True

    try:
        base_args = shlex.split(definition.command, posix=(os.name != "nt"))
    except ValueError:
        return build_run_display_command(definition, extra_args), True

    if not base_args:
        raise RunUsageError(f"脚本 '{definition.name}' 的命令不能为空")
    return [*base_args, *extra_args], False


def build_entrypoint_runner_command(
    definition: ScriptDefinition,
    extra_args: Sequence[str],
    python_executable: Optional[str] = None,
) -> list[str]:
    """Build the subprocess command used to execute an entry-point script."""
    return [
        python_executable or sys.executable,
        "-c",
        ENTRYPOINT_RUNNER_CODE,
        definition.name,
        definition.command,
        *extra_args,
    ]


def format_scripts(scripts: Mapping[str, ScriptDefinition]) -> str:
    """Format scripts for CLI output."""
    rows = ["可用脚本:"]
    for name in sorted(scripts):
        definition = scripts[name]
        if definition.source == QPIP_SCRIPTS_TABLE:
            command = (
                definition.command
                if isinstance(definition.command, str)
                else format_subprocess_command(definition.command)
            )
            suffix = " [argv]" if definition.kind == "argv" else ""
            rows.append(f"  {name:<12} {command}{suffix}")
        else:
            rows.append(f"  {name:<12} {definition.command} [{definition.source}]")
    return "\n".join(rows)


def format_run_header(script_name: str, rendered_command: str) -> str:
    """Render npm-style run output."""
    return f"> qpip run {script_name}\n> {rendered_command}"


def missing_scripts_message() -> str:
    """Return the CLI message for projects without supported script tables."""
    return (
        f"当前项目未配置 [{QPIP_SCRIPTS_TABLE}]，"
        f"也未找到 [{PROJECT_SCRIPTS_TABLE}]、[{PROJECT_GUI_SCRIPTS_TABLE}] 或 [{POETRY_SCRIPTS_TABLE}]"
    )


def run_project_command(
    args: Sequence[str],
    *,
    cwd: Optional[Path] = None,
    output: Optional[TextIO] = None,
    dry_run: bool = False,
    env: Optional[Mapping[str, str]] = None,
    os_name: Optional[str] = None,
) -> int:
    """Handle `qpip run` by reading commands from pyproject.toml."""
    target_dir = Path.cwd() if cwd is None else cwd
    out = output if output is not None else os.sys.stdout
    pyproject_path = find_pyproject_path(target_dir)
    scripts = load_project_scripts(pyproject_path)
    script_name, extra_args = parse_run_args(args)

    if script_name is None:
        if not scripts:
            raise RunUsageError(missing_scripts_message())
        print(format_scripts(scripts), file=out)
        return 0

    if not scripts:
        raise RunUsageError(missing_scripts_message())

    try:
        definition = scripts[script_name]
    except KeyError as exc:
        available_scripts = ", ".join(sorted(scripts))
        suggestions = get_close_matches(script_name, list(scripts), n=3, cutoff=0.4)
        suggestion_text = f"；你是不是想运行: {', '.join(suggestions)}" if suggestions else ""
        raise RunUsageError(
            f"未找到脚本 '{script_name}'{suggestion_text}。可用脚本: {available_scripts}"
        ) from exc

    rendered_command = build_run_display_command(definition, extra_args)
    print(format_run_header(script_name, rendered_command), file=out)

    if dry_run:
        return 0

    project_root = pyproject_path.parent
    subprocess_env = None
    env_python = None
    if project_env_exists(project_root):
        subprocess_env = build_env_execution_env(project_root, env=env, os_name=os_name)
        env_python = str(get_env_python_path(project_root, os_name=os_name))

    if definition.kind in {"shell", "argv"}:
        shell_command, uses_shell = build_shell_script_command(definition, extra_args)
        result = subprocess.run(
            shell_command,
            shell=uses_shell,
            cwd=str(project_root),
            check=False,
            env=subprocess_env,
        )
        return result.returncode

    command = build_entrypoint_runner_command(
        definition,
        extra_args,
        python_executable=env_python,
    )
    result = subprocess.run(command, cwd=str(project_root), check=False, env=subprocess_env)
    return result.returncode
