"""
Generate coding-API wrapper files for all ToolUniverse tools.

Run via ``tu build`` after adding or modifying tool configurations.
Each tool in the registry gets its own ``tools/<ToolName>.py`` module
containing a type-annotated function that calls ``run_one_function()``
under the hood, and ``tools/__init__.py`` is regenerated to import them all.

Only files that carry the AUTO-GENERATED marker are managed (written or
deleted). Files without the marker (hand-written helpers like
``_shared_client.py``) are never touched.
"""

import keyword
import sys
from pathlib import Path

# Marker placed on the first line of every generated file.
_MARKER = "# AUTO-GENERATED by `tu build` — do not edit manually"

_TOOLS_DIR = Path(__file__).parent / "tools"

# JSON schema type → Python type annotation string
_TYPE_MAP: dict[str, str] = {
    "string": "str",
    "integer": "int",
    "number": "float",
    "boolean": "bool",
    "array": "list",
    "object": "dict",
}

# Static header of tools/__init__.py (tool imports are appended after)
_INIT_HEADER = """\
{marker}
\"\"\"ToolUniverse Tools

Type-safe Python interface to {n} scientific tools.
Each tool is in its own module for minimal import overhead.

Usage:
    from tooluniverse.tools import UniProt_get_entry_by_accession
    result = UniProt_get_entry_by_accession(accession="P05067")
\"\"\"

from tooluniverse.exceptions import (
    ToolAuthError,
    ToolConfigError,
    ToolDependencyError,
    ToolError,
    ToolRateLimitError,
    ToolServerError,
    ToolUnavailableError,
    ToolValidationError,
)
from ._shared_client import get_shared_client, reset_shared_client

"""


# ── helpers ────────────────────────────────────────────────────────────────────


def _py_type(json_type) -> str:
    if isinstance(json_type, list):
        # e.g. ["string", "null"] → use first non-null type
        for t in json_type:
            if t != "null":
                return _TYPE_MAP.get(t, "Any")
        return "Any"
    return _TYPE_MAP.get(json_type, "Any")


def _clean_desc(text: str) -> str:
    """Collapse whitespace and escape special sequences for use inside docstrings."""
    if not text:
        return ""
    text = " ".join(text.split())
    text = text.replace('"""', "'''")
    # Escape backslashes so Windows paths like C:\Users don't become Unicode escapes
    text = text.replace("\\", "\\\\")
    return text


def _is_valid_tool_name(name: str) -> bool:
    """Return True only for names that are safe Python identifiers."""
    return name.isidentifier() and not name.startswith("_") and not keyword.iskeyword(name)


def _safe_param(name: str) -> str:
    """Convert a parameter name to a valid Python identifier.

    - Replaces hyphens with underscores (e.g. ``dist-max`` → ``dist_max``)
    - Appends ``_`` to Python reserved words (e.g. ``from`` → ``from_``)
    """
    safe = name.replace("-", "_")
    if keyword.iskeyword(safe):
        safe += "_"
    if safe and safe[0].isdigit():
        safe = "_" + safe
    return safe


# ── per-tool file generator ────────────────────────────────────────────────────


def _generate_wrapper(tool_name: str, config: dict) -> str:
    """Return the complete source of ``tools/<tool_name>.py``."""
    desc = _clean_desc(config.get("description", ""))
    schema = config.get("parameter") or {}
    props: dict = schema.get("properties") or {}
    required: set = set(schema.get("required") or [])

    req = [(k, v) for k, v in props.items() if k in required]
    opt = [(k, v) for k, v in props.items() if k not in required]
    all_params = req + opt

    # Detect parameter name collisions after sanitisation (e.g. foo-bar vs foo_bar)
    _seen_safe: dict[str, str] = {}
    for pname, _ in all_params:
        safe = _safe_param(pname)
        if safe in _seen_safe:
            raise ValueError(
                f"Parameter name collision in {tool_name!r}: "
                f"{pname!r} and {_seen_safe[safe]!r} both map to {safe!r}"
            )
        _seen_safe[safe] = pname

    # ── imports ──────────────────────────────────────────────────────
    # Optional / Callable only needed when there are params or always for
    # the standard kwargs; Any is always needed for the return type.
    lines: list[str] = [
        _MARKER,
        f'"""{tool_name}',
        "",
        f"{desc}",
        '"""',
        "",
        "from typing import Any, Callable, Optional",
        "",
        "from ._shared_client import get_shared_client",
        "",
        "",
    ]

    # ── function signature ────────────────────────────────────────────
    lines.append(f"def {tool_name}(")
    for pname, pdef in req:
        ptype = _py_type(pdef.get("type", ""))
        lines.append(f"    {_safe_param(pname)}: {ptype},")
    for pname, pdef in opt:
        ptype = _py_type(pdef.get("type", ""))
        lines.append(f"    {_safe_param(pname)}: Optional[{ptype}] = None,")
    lines += [
        "    *,",
        "    stream_callback: Optional[Callable[[str], None]] = None,",
        "    use_cache: bool = False,",
        "    validate: bool = True,",
        ") -> dict[str, Any]:",
    ]

    # ── docstring ─────────────────────────────────────────────────────
    lines.append('    """')
    lines.append(f"    {desc}")
    if all_params:
        lines += ["", "    Parameters", "    ----------"]
        for pname, pdef in all_params:
            ptype = _py_type(pdef.get("type", ""))
            pdesc = _clean_desc(pdef.get("description", ""))
            lines.append(f"    {_safe_param(pname)} : {ptype}")
            if pdesc:
                lines.append(f"        {pdesc}")
    lines += [
        "",
        "    stream_callback : Callable, optional",
        "        Callback for streaming output",
        "    use_cache : bool, default False",
        "        Enable caching",
        "    validate : bool, default True",
        "        Validate parameters",
        "",
        "    Returns",
        "    -------",
        "    dict[str, Any]",
        '    """',
    ]

    # ── body ──────────────────────────────────────────────────────────
    # Use original key names in arguments dict (the API expects them as-is),
    # but use the sanitized Python identifier as the variable name.
    if all_params:
        pairs = ", ".join(f'"{k}": {_safe_param(k)}' for k, _ in all_params)
        arguments = "{" + pairs + "}"
    else:
        arguments = "{}"

    lines += [
        "    return get_shared_client().run_one_function(",
        "        {",
        f'            "name": "{tool_name}",',
        f'            "arguments": {arguments},',
        "        },",
        "        stream_callback=stream_callback,",
        "        use_cache=use_cache,",
        "        validate=validate,",
        "    )",
        "",
        "",
        f'__all__ = ["{tool_name}"]',
        "",
    ]

    return "\n".join(lines)


# ── __init__.py generator ─────────────────────────────────────────────────────


def _generate_init(tool_names: list[str]) -> str:
    """Return the complete source of ``tools/__init__.py``.

    Merges *tool_names* (tools loaded in this run) with any pre-existing
    wrapper files that lack the AUTO-GENERATED marker (e.g. tools gated
    behind API keys that were not available during this build).
    """
    # Include wrapper files already on disk even if not loaded this run
    on_disk = {
        f.stem
        for f in _TOOLS_DIR.glob("*.py")
        if not f.name.startswith("_") and _is_valid_tool_name(f.stem)
    }
    all_names = sorted(set(tool_names) | on_disk)
    header = _INIT_HEADER.format(marker=_MARKER, n=len(all_names))
    imports = "\n".join(f"from .{name} import {name}" for name in all_names)
    return header + imports + "\n"


# ── file I/O helpers ──────────────────────────────────────────────────────────


def _write_if_changed(path: Path, content: str) -> bool:
    """Write content to path only when it differs. Returns True if written."""
    if path.exists() and path.read_text(encoding="utf-8") == content:
        return False
    path.write_text(content, encoding="utf-8")
    return True


def _is_generated(path: Path) -> bool:
    """Return True when the file's first line is the AUTO-GENERATED marker."""
    try:
        first = path.read_text(encoding="utf-8").split("\n", 1)[0]
        return first == _MARKER
    except OSError:
        return False


# ── main entry point ──────────────────────────────────────────────────────────


def main() -> None:
    """Generate all coding-API wrapper files and ``tools/__init__.py``."""
    # Load ToolUniverse -------------------------------------------------------
    print("Loading tools…", end="", flush=True)
    from tooluniverse import ToolUniverse

    tu = ToolUniverse()
    tu.load_tools()
    total = len(tu.all_tools)
    print(f" {total} tools loaded.")

    # Collect eligible tools --------------------------------------------------
    items = [
        (name, cfg)
        for name, cfg in tu.all_tool_dict.items()
        if _is_valid_tool_name(name)
    ]

    _TOOLS_DIR.mkdir(exist_ok=True)
    valid_names = {name for name, _ in items}

    # Write per-tool wrappers -------------------------------------------------
    written = skipped = 0
    for tool_name, config in items:
        content = _generate_wrapper(tool_name, config)
        if _write_if_changed(_TOOLS_DIR / f"{tool_name}.py", content):
            written += 1
        else:
            skipped += 1
    print(f"  Wrappers: {written} written, {skipped} unchanged.")

    # Remove stale generated wrappers -----------------------------------------
    removed = 0
    for existing in _TOOLS_DIR.glob("*.py"):
        if existing.name.startswith("_"):
            continue  # never touch hand-written helpers
        if existing.stem not in valid_names and _is_generated(existing):
            existing.unlink()
            removed += 1
    if removed:
        print(f"  Removed {removed} stale wrapper(s).")

    # Regenerate __init__.py --------------------------------------------------
    init_path = _TOOLS_DIR / "__init__.py"
    if _write_if_changed(init_path, _generate_init(sorted(valid_names))):
        print("  __init__.py updated.")
    else:
        print("  __init__.py unchanged.")

    # Lint check (ruff) -------------------------------------------------------
    try:
        import subprocess

        result = subprocess.run(
            [sys.executable, "-m", "ruff", "check", str(_TOOLS_DIR)],
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            print("\nruff issues found in generated files:")
            print(result.stdout[:2000])
        else:
            print("  ruff check passed.")
    except Exception:
        pass  # ruff not installed — skip silently

    print("Done.")


if __name__ == "__main__":
    main()
