"""Project-level qpip configuration helpers."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import re
from typing import Optional

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - exercised on Python < 3.11
    tomllib = None


QPIP_CONFIG_TABLE = "tool.qpip"
TABLE_HEADER_PATTERN = re.compile(r"^\[(?P<name>[^\]]+)\]\s*$")


class ProjectConfigError(ValueError):
    """Raised when project configuration is invalid."""


@dataclass(frozen=True)
class ProjectConfig:
    """Supported qpip project configuration."""

    mirror: Optional[str] = None
    venv_dirname: Optional[str] = None
    source_path: Optional[Path] = None


@dataclass(frozen=True)
class ProjectConfigLoadResult:
    """Project config plus a non-fatal parse warning."""

    config: ProjectConfig
    warning: Optional[str] = None


def find_pyproject_upwards(cwd: Path) -> Optional[Path]:
    """Find the nearest pyproject.toml from cwd upwards."""
    current = cwd.resolve()
    for candidate in (current, *current.parents):
        pyproject_path = candidate / "pyproject.toml"
        if pyproject_path.exists():
            return pyproject_path
    return None


def parse_config_key(key: str) -> str:
    """Parse a simple TOML key used in [tool.qpip]."""
    raw = key.strip()
    if not raw:
        raise ProjectConfigError("[tool.qpip] 中存在空键")
    if raw[0] in {'"', "'"}:
        return parse_config_value(raw)
    return raw


def parse_config_value(value: str) -> str:
    """Parse a one-line TOML string value."""
    raw = value.lstrip()
    if not raw:
        raise ProjectConfigError("[tool.qpip] 配置值不能为空")

    if raw[0] == '"':
        return parse_toml_basic_string(raw)

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

    raise ProjectConfigError("[tool.qpip] 当前仅支持单行字符串值")


def parse_toml_basic_string(raw: str) -> str:
    """Parse a one-line TOML basic string."""
    chars: list[str] = []
    index = 1
    while index < len(raw):
        char = raw[index]
        if char == '"':
            remainder = raw[index + 1 :].strip()
            if remainder and not remainder.startswith("#"):
                raise ProjectConfigError("[tool.qpip] 配置格式无效")
            return "".join(chars)
        if char == "\\":
            index += 1
            if index >= len(raw):
                raise ProjectConfigError("[tool.qpip] 字符串缺少结束引号")
            escape = raw[index]
            if escape == "b":
                chars.append("\b")
            elif escape == "t":
                chars.append("\t")
            elif escape == "n":
                chars.append("\n")
            elif escape == "f":
                chars.append("\f")
            elif escape == "r":
                chars.append("\r")
            elif escape == '"':
                chars.append('"')
            elif escape == "\\":
                chars.append("\\")
            elif escape == "u":
                chars.append(parse_unicode_escape(raw, index + 1, 4))
                index += 4
            elif escape == "U":
                chars.append(parse_unicode_escape(raw, index + 1, 8))
                index += 8
            else:
                raise ProjectConfigError("[tool.qpip] 包含不支持的转义序列")
            index += 1
            continue
        chars.append(char)
        index += 1

    raise ProjectConfigError("[tool.qpip] 字符串缺少结束引号")


def parse_unicode_escape(raw: str, start: int, length: int) -> str:
    """Parse a TOML unicode escape sequence."""
    chunk = raw[start : start + length]
    if len(chunk) != length or any(char not in "0123456789abcdefABCDEF" for char in chunk):
        raise ProjectConfigError("[tool.qpip] Unicode 转义格式无效")
    return chr(int(chunk, 16))


def strip_toml_comment(line: str) -> str:
    """Strip TOML comments while respecting quoted strings."""
    in_double = False
    in_single = False
    escaped = False

    for index, char in enumerate(line):
        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 == "#":
            return line[:index]
    return line


def parse_table_header(line: str) -> Optional[str]:
    """Parse a TOML table header, allowing trailing comments."""
    stripped = strip_toml_comment(line).strip()
    if not stripped:
        return None
    table_match = TABLE_HEADER_PATTERN.match(stripped)
    if table_match:
        return table_match.group("name").strip()
    return None


def validate_pyproject_for_editing(content: str) -> None:
    """Validate the file before rewriting project config."""
    if tomllib is not None:
        try:
            tomllib.loads(content)
        except tomllib.TOMLDecodeError as exc:
            raise ProjectConfigError(f"pyproject.toml 解析失败: {exc}") from exc
        return

    validate_pyproject_structure(content)


def validate_pyproject_structure(content: str) -> None:
    """Perform a conservative structural validation without tomllib."""
    bracket_depth = 0
    brace_depth = 0
    in_double = False
    in_single = False
    escaped = False
    awaiting_assignment = False

    for lineno, line in enumerate(content.splitlines(), start=1):
        stripped = strip_toml_comment(line).strip()
        if not stripped:
            continue
        table_name = parse_table_header(line)
        if table_name is not None:
            if awaiting_assignment or bracket_depth or brace_depth or in_double or in_single:
                raise ProjectConfigError(f"pyproject.toml 解析失败: 第 {lineno} 行附近存在未闭合值")
            continue

        if not awaiting_assignment:
            if "=" not in stripped:
                raise ProjectConfigError(f"pyproject.toml 解析失败: 第 {lineno} 行缺少 '='")
            _, _, value_part = stripped.partition("=")
            if not value_part.strip():
                raise ProjectConfigError(f"pyproject.toml 解析失败: 第 {lineno} 行缺少配置值")
            segment = value_part
        else:
            segment = stripped

        for char in segment:
            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
            elif char == "'":
                in_single = True
            elif char == "[":
                bracket_depth += 1
            elif char == "]":
                bracket_depth -= 1
                if bracket_depth < 0:
                    raise ProjectConfigError(f"pyproject.toml 解析失败: 第 {lineno} 行存在多余的 ']'")
            elif char == "{":
                brace_depth += 1
            elif char == "}":
                brace_depth -= 1
                if brace_depth < 0:
                    raise ProjectConfigError(f"pyproject.toml 解析失败: 第 {lineno} 行存在多余的 '}}'")

        awaiting_assignment = bracket_depth > 0 or brace_depth > 0 or in_double or in_single

    if awaiting_assignment or bracket_depth or brace_depth or in_double or in_single:
        raise ProjectConfigError("pyproject.toml 解析失败: 文件末尾存在未闭合值")


def load_qpip_config_table(pyproject_path: Path) -> dict[str, str]:
    """Load the [tool.qpip] table as string key/value pairs."""
    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()
        current_table_name = parse_table_header(line)
        if current_table_name is not None:
            if in_target_section:
                break
            if current_table_name == QPIP_CONFIG_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 ProjectConfigError(f"[{QPIP_CONFIG_TABLE}] 中存在无效行")
        items[parse_config_key(key_part)] = parse_config_value(value_part)

    return items if found_target_section else {}


def load_project_config(cwd: Path) -> ProjectConfig:
    """Load project-level qpip configuration from pyproject.toml if present."""
    pyproject_path = find_pyproject_upwards(cwd)
    if pyproject_path is None:
        return ProjectConfig()

    qpip_table = load_qpip_config_table(pyproject_path)
    if not qpip_table:
        return ProjectConfig()

    mirror = qpip_table.get("mirror")
    venv_dirname = qpip_table.get("venv-dir", qpip_table.get("venv_dir"))
    return ProjectConfig(
        mirror=mirror,
        venv_dirname=venv_dirname,
        source_path=pyproject_path,
    )


def load_project_config_safe(cwd: Path) -> ProjectConfigLoadResult:
    """Load project config and downgrade parse failures to warnings."""
    try:
        return ProjectConfigLoadResult(config=load_project_config(cwd))
    except ProjectConfigError as exc:
        return ProjectConfigLoadResult(config=ProjectConfig(), warning=str(exc))


def render_project_config_lines(config: ProjectConfig) -> list[str]:
    """Render [tool.qpip] lines for the provided config."""
    lines = [f"[{QPIP_CONFIG_TABLE}]"]
    if config.mirror is not None:
        lines.append(f'mirror = "{config.mirror}"')
    if config.venv_dirname is not None:
        lines.append(f'venv-dir = "{config.venv_dirname}"')
    return lines


def render_toml_string(value: str) -> str:
    """Render a TOML string value."""
    escaped = (
        value.replace("\\", "\\\\")
        .replace("\b", "\\b")
        .replace("\t", "\\t")
        .replace("\n", "\\n")
        .replace("\f", "\\f")
        .replace("\r", "\\r")
        .replace('"', '\\"')
    )
    return f'"{escaped}"'


def find_config_section_range(lines: list[str]) -> tuple[Optional[int], Optional[int]]:
    """Return the start/end indices of [tool.qpip] within lines."""
    start = None
    end = None

    for index, line in enumerate(lines):
        table_name = parse_table_header(line)
        if table_name == QPIP_CONFIG_TABLE:
            start = index
            continue
        if start is not None and table_name is not None:
            end = index
            break

    return start, end


def update_project_config(cwd: Path, **changes: Optional[str]) -> ProjectConfig:
    """Update project-level qpip config inside pyproject.toml."""
    pyproject_path = find_pyproject_upwards(cwd)
    if pyproject_path is None:
        raise ProjectConfigError("当前目录及其上级目录未找到 pyproject.toml")

    # Validate the whole file before editing to avoid rewriting malformed TOML.
    content = pyproject_path.read_text(encoding="utf-8")
    validate_pyproject_for_editing(content)
    current = load_project_config(cwd)
    next_config = ProjectConfig(
        mirror=changes.get("mirror", current.mirror),
        venv_dirname=changes.get("venv_dirname", current.venv_dirname),
        source_path=pyproject_path,
    )

    lines = content.splitlines()
    start, end = find_config_section_range(lines)

    if start is None:
        rendered_lines = render_project_config_lines(next_config)
        if not content:
            separator = ""
        elif content.endswith("\n\n"):
            separator = ""
        elif content.endswith("\n"):
            separator = "\n"
        else:
            separator = "\n\n"
        pyproject_path.write_text(content + separator + "\n".join(rendered_lines) + "\n", encoding="utf-8")
        return next_config

    if end is None:
        end = len(lines)

    section_lines = lines[start:end]
    updated_section: list[str] = [section_lines[0]]
    seen_keys: set[str] = set()
    target_values = {
        "mirror": next_config.mirror,
        "venv-dir": next_config.venv_dirname,
        "venv_dir": next_config.venv_dirname,
    }

    for raw_line in section_lines[1:]:
        stripped = raw_line.strip()
        if not stripped or stripped.startswith("#"):
            updated_section.append(raw_line)
            continue

        key_part, separator, _value_part = raw_line.partition("=")
        if not separator:
            updated_section.append(raw_line)
            continue

        try:
            key = parse_config_key(key_part)
        except ProjectConfigError:
            updated_section.append(raw_line)
            continue

        if key not in target_values:
            updated_section.append(raw_line)
            continue

        if key in seen_keys:
            continue
        seen_keys.add(key)

        mapped_key = "venv-dir" if key in {"venv-dir", "venv_dir"} else key
        value = target_values[mapped_key]
        if value is None:
            continue
        updated_section.append(f"{mapped_key} = {render_toml_string(value)}")

    if next_config.mirror is not None and "mirror" not in seen_keys:
        updated_section.append(f'mirror = {render_toml_string(next_config.mirror)}')
    if next_config.venv_dirname is not None and not ({"venv-dir", "venv_dir"} & seen_keys):
        updated_section.append(f'venv-dir = {render_toml_string(next_config.venv_dirname)}')

    new_lines = [*lines[:start], *updated_section, *lines[end:]]
    pyproject_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
    return next_config
