"""Teach — import existing Python scripts as Alive skills."""

from __future__ import annotations

import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from rich.console import Console
from rich.panel import Panel
from rich.syntax import Syntax

from alive.core.skill_builder import _extract_block, _safe_eval_dict, _safe_parse_json
from alive.core.skill_registry import SkillInfo, SkillRegistry, get_registry
from alive.llm.base import LLMProvider
from alive.sandbox.dependency import get_dependency_manager
from alive.sandbox.runner import run_skill_code


# ── Data structures ─────────────────────────────────────────────────────────


@dataclass
class TeachResult:
    """Result of teaching Alive a new skill from an existing script."""

    skill_name: str
    description: str
    original_code: str
    adapted_code: str
    test_code: str
    dependencies: list[str] = field(default_factory=list)
    input_schema: dict[str, Any] = field(default_factory=dict)
    output_schema: dict[str, Any] = field(default_factory=dict)
    sample_input: dict[str, Any] = field(default_factory=dict)
    changes: list[str] = field(default_factory=list)
    source_path: str = ""
    source_lines: int = 0


# ── LLM prompt ──────────────────────────────────────────────────────────────

_TEACH_SYSTEM = """\
You are Alive's skill adapter. Given a Python script, rewrite it to conform \
to the Alive skill format.

SKILL FORMAT RULES:
- A single file with a `run()` function as the entry point
- The function accepts keyword arguments with type hints and returns a dict
- The return dict must include 'result' and 'success' keys
- Type hints on all parameters and return type
- Handle errors gracefully — return {{"success": False, "error": "..."}} \
rather than raising exceptions
- Preserve ALL the original logic — do NOT simplify or remove features
- Only use standard library OR well-known PyPI packages
- Never use input() or any interactive I/O

You MUST respond in EXACTLY this format with these labeled code blocks:

```description
One-line description of what this skill does.
```

```dependencies
package1
package2
```

```input_schema
{{"param1": "str", "param2": "int (optional, default 10)"}}
```

```output_schema
{{"result": "str", "success": "bool"}}
```

```python skill
# The complete adapted skill.py — preserving ALL original logic
```

```python test
# Tests — must use: from skill import run
```

```python sample_input
{{"param1": "example value"}}
```

```changes
+ Wrapped main logic in run()
+ Added return dict with result/success keys
+ Extracted 3 dependencies: httpx, bs4, lxml
```

IMPORTANT:
- Tests must import with `from skill import run` (not a package path)
- sample_input must be a valid Python dict that exercises the happy path
- Dependencies must NOT include standard library modules
- Preserve ALL original functionality — this is an adaptation, not a rewrite
- The changes block should list what you changed (+ for additions, \
- for removals, ~ for modifications)"""


# ── Source fetching ─────────────────────────────────────────────────────────


def _fetch_source(source: str) -> tuple[str, str]:
    """Fetch source code from a local path or URL.

    Returns:
        Tuple of (code, resolved_path_or_url).

    Raises:
        FileNotFoundError: If local file does not exist.
        ConnectionError: If URL fetch fails.
    """
    if source.startswith(("http://", "https://")):
        try:
            req = urllib.request.Request(
                source,
                headers={"User-Agent": "alive-agent/1.0"},
            )
            with urllib.request.urlopen(req, timeout=15) as resp:
                code = resp.read().decode("utf-8")
            return code, source
        except (urllib.error.URLError, urllib.error.HTTPError, OSError) as exc:
            raise ConnectionError(
                f"Failed to fetch script from URL: {source}\n{exc}"
            ) from exc

    path = Path(source).resolve()
    if not path.exists():
        raise FileNotFoundError(f"Script not found: {path}")
    if not path.is_file():
        raise FileNotFoundError(f"Not a file: {path}")

    code = path.read_text(encoding="utf-8")
    return code, str(path)


# ── Import extraction ───────────────────────────────────────────────────────

# Known stdlib top-level module names (Python 3.10+)
_STDLIB_MODULES: frozenset[str] = (
    frozenset(sys.stdlib_module_names)
    if hasattr(sys, "stdlib_module_names")
    else frozenset()
)


def _extract_imports(code: str) -> list[str]:
    """Extract third-party package names from import statements.

    Filters out standard library modules.

    Returns:
        Sorted list of unique third-party package names.
    """
    packages: set[str] = set()

    # Match: import foo, import foo.bar, from foo import bar, from foo.bar import baz
    for match in re.finditer(
        r"^\s*(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)", code, re.MULTILINE
    ):
        top_level = match.group(1)
        packages.add(top_level)

    # Filter stdlib
    third_party = {
        pkg
        for pkg in packages
        if pkg not in _STDLIB_MODULES
        and pkg not in {"__future__", "typing", "typing_extensions"}
    }

    return sorted(third_party)


# ── Raw wrapping ────────────────────────────────────────────────────────────


def _has_main_function(code: str) -> bool:
    """Check if the code defines a main() function."""
    return bool(re.search(r"^def\s+main\s*\(", code, re.MULTILINE))


def _wrap_raw(code: str, name: str, source_path: str) -> TeachResult:
    """Wrap a script in a minimal run() function without LLM adaptation.

    If the script defines a main() function, the wrapper calls it.
    Otherwise, it executes the script body and returns success.
    """
    source_lines = len(code.splitlines())
    dependencies = _extract_imports(code)
    has_main = _has_main_function(code)

    if has_main:
        wrapper = '''

def run(**kwargs) -> dict:
    """Entry point for Alive skill execution."""
    try:
        result = main(**kwargs)
        if isinstance(result, dict):
            if "success" not in result:
                result["success"] = True
            return result
        return {"result": result, "success": True}
    except Exception as e:
        return {"result": None, "success": False, "error": str(e)}
'''
    else:
        wrapper = '''

def run(**kwargs) -> dict:
    """Entry point for Alive skill execution."""
    try:
        return {"result": "Script executed successfully", "success": True}
    except Exception as e:
        return {"result": None, "success": False, "error": str(e)}
'''

    adapted_code = (
        f"# Original script: {name}\n"
        f"# Auto-wrapped by: alive teach --raw\n\n"
        f"{code}\n{wrapper}"
    )

    changes = ["Wrapped in minimal run() function (--raw mode)"]
    if has_main:
        changes.append("run() delegates to existing main() function")
    if dependencies:
        changes.append(
            f"Detected {len(dependencies)} "
            f"{'dependency' if len(dependencies) == 1 else 'dependencies'}: "
            f"{', '.join(dependencies)}"
        )

    return TeachResult(
        skill_name=name,
        description=f"Skill taught from {Path(source_path).name}",
        original_code=code,
        adapted_code=adapted_code,
        test_code=(
            f'"""Auto-generated tests for {name}."""\n\n'
            f"from skill import run\n\n\n"
            f"def test_{name}_runs():\n"
            f'    result = run()\n'
            f'    assert isinstance(result, dict)\n'
            f'    assert "success" in result\n'
        ),
        dependencies=dependencies,
        input_schema={},
        output_schema={"result": "Any", "success": "bool"},
        sample_input={},
        changes=changes,
        source_path=source_path,
        source_lines=source_lines,
    )


# ── LLM adaptation ─────────────────────────────────────────────────────────


async def _adapt_with_llm(
    code: str,
    name: str,
    source_path: str,
    *,
    provider: LLMProvider | None = None,
) -> TeachResult:
    """Adapt a script to skill format using the LLM.

    Sends the script to the LLM with instructions to rewrite it as an
    Alive skill while preserving all original logic.
    """
    if provider is None:
        from alive.llm import get_provider

        provider = get_provider()

    source_lines = len(code.splitlines())

    user_prompt = (
        f'Adapt this Python script into an Alive skill named "{name}".\n\n'
        f"Source: {source_path} ({source_lines} lines)\n\n"
        f"```python\n{code}\n```\n\n"
        f"Rewrite it to use the Alive skill format with a run() function."
    )

    response = await provider.complete(_TEACH_SYSTEM, user_prompt)

    # Parse response using shared skill_builder helpers
    adapted_code = _extract_block("skill", response)
    if not adapted_code:
        raise ValueError(
            "LLM failed to produce adapted skill code. "
            "Try again or use --raw to skip LLM adaptation."
        )

    description = _extract_block("description", response) or f"Skill taught from {Path(source_path).name}"

    test_code = _extract_block("test", response) or (
        f'"""Auto-generated tests for {name}."""\n\n'
        f"from skill import run\n\n\n"
        f"def test_{name}_runs():\n"
        f"    result = run()\n"
        f'    assert isinstance(result, dict)\n'
        f'    assert "success" in result\n'
    )

    deps_raw = _extract_block("dependencies", response)
    dependencies = (
        [d.strip() for d in deps_raw.splitlines() if d.strip()]
        if deps_raw
        else _extract_imports(adapted_code)
    )

    input_schema = _safe_parse_json(_extract_block("input_schema", response))
    output_schema = _safe_parse_json(_extract_block("output_schema", response))

    sample_raw = _extract_block("sample_input", response)
    sample_input = _safe_eval_dict(sample_raw) if sample_raw else {}

    changes_raw = _extract_block("changes", response)
    changes = (
        [line.strip() for line in changes_raw.splitlines() if line.strip()]
        if changes_raw
        else ["Adapted to Alive skill format"]
    )

    return TeachResult(
        skill_name=name,
        description=description,
        original_code=code,
        adapted_code=adapted_code,
        test_code=test_code,
        dependencies=dependencies,
        input_schema=input_schema,
        output_schema=output_schema,
        sample_input=sample_input,
        changes=changes,
        source_path=source_path,
        source_lines=source_lines,
    )


# ── Display ─────────────────────────────────────────────────────────────────


def display_teach_result(
    result: TeachResult,
    *,
    test_passed: bool,
    console: Console | None = None,
) -> None:
    """Render the teach result as a rich panel."""
    con = console or Console()

    lines = [
        f"[bold]Source:[/] {result.source_path} ({result.source_lines} lines)",
        "",
    ]

    if result.changes:
        lines.append("[bold]Changes made:[/]")
        for change in result.changes:
            # Ensure each change starts with a symbol
            if not change.startswith(("+", "-", "~")):
                change = f"+ {change}"
            lines.append(f"  {change}")
        lines.append("")

    if test_passed:
        lines.append("[green]\u2713 Tests passed[/]")
    else:
        lines.append("[red]\u2717 Tests failed[/]")

    lines.append("")
    lines.append(f"[bold]Skill saved:[/] {result.skill_name}")

    con.print(Panel(
        "\n".join(lines),
        title="[bold cyan]Teaching Alive[/]",
        border_style="cyan",
    ))


# ── Core teach function ─────────────────────────────────────────────────────


async def teach_skill(
    source: str,
    name: str,
    *,
    raw: bool = False,
    provider: LLMProvider | None = None,
    registry: SkillRegistry | None = None,
    console: Console | None = None,
) -> SkillInfo:
    """Teach Alive a new skill from an existing Python script.

    Reads the script (from file or URL), adapts it to the skill format
    (using LLM or raw wrapping), tests in sandbox, and registers.

    Args:
        source: Path to a Python file or URL.
        name: Name for the new skill (snake_case).
        raw: If True, skip LLM adaptation and wrap minimally.
        provider: LLM provider override. Only used when raw=False.
        registry: SkillRegistry override. Defaults to global registry.
        console: Rich console for output.

    Returns:
        SkillInfo for the newly registered skill.

    Raises:
        FileNotFoundError: If the source file or URL cannot be found.
        ConnectionError: If URL fetch fails.
        ValueError: If the script cannot be adapted.
        RuntimeError: If tests fail in sandbox.
        FileExistsError: If a skill with this name already exists.
    """
    con = console or Console()
    registry = registry or get_registry()

    # Step 1: Fetch source
    code, resolved_path = _fetch_source(source)
    source_lines = len(code.splitlines())

    con.print(Panel(
        f"[bold]Source:[/] {resolved_path} ({source_lines} lines)\n"
        f"[bold]Mode:[/] {'raw (no LLM)' if raw else 'LLM adaptation'}",
        title="[bold cyan]Teaching Alive[/]",
        border_style="cyan",
    ))

    # Step 2: Adapt
    if raw:
        result = _wrap_raw(code, name, resolved_path)
    else:
        with con.status("[bold cyan]  Adapting to skill format...", spinner="dots"):
            result = await _adapt_with_llm(
                code, name, resolved_path, provider=provider
            )

    # Step 3: Install dependencies
    if result.dependencies:
        with con.status("[bold cyan]  Installing dependencies...", spinner="dots"):
            dm = get_dependency_manager()
            dm.install(result.dependencies)

    # Step 4: Test in sandbox
    with con.status("[bold cyan]  Testing in sandbox...", spinner="dots"):
        sr = run_skill_code(result.adapted_code, result.sample_input)

    if not sr.success:
        con.print(Panel(
            f"Tests failed:\n\n{sr.error or 'Unknown error'}",
            title="[bold red]Test Failed[/]",
            border_style="red",
        ))
        raise RuntimeError(
            f"Taught skill '{name}' failed testing. "
            f"Error: {sr.error or 'Unknown error'}\n"
            f"Try --raw to skip LLM adaptation, or fix the script manually."
        )

    con.print("  [bold green]\u2713 Tests passed.[/]")

    # Step 5: Register
    skill_info = registry.register(
        name=name,
        code=result.adapted_code,
        description=result.description,
        dependencies=result.dependencies,
        input_schema=result.input_schema,
        output_schema=result.output_schema,
        sample_input=result.sample_input,
        test_code=result.test_code,
        skill_doc=(
            f"# {name}\n\n"
            f"{result.description}\n\n"
            f"## Origin\n\n"
            f"Taught from: `{result.source_path}`\n\n"
            f"## Changes\n\n"
            + "\n".join(f"- {c}" for c in result.changes)
            + "\n"
        ),
    )

    # Step 6: Display result
    display_teach_result(result, test_passed=True, console=con)

    return skill_info
