"""Command-line interface for dotpromptz.

Usage::

    runprompt my_prompt.prompt
    runprompt batch_prompts.prompt
"""
from __future__ import annotations

import asyncio
import json
import sys
from pathlib import Path
from typing import Any

import click
from dotenv import load_dotenv

try:
    import yaml
except ImportError:
    yaml = None  # type: ignore

from dotpromptz import Dotprompt
from dotpromptz.adapters import get_adapter, list_adapters
from dotpromptz.input_resolver import resolve_input
from dotpromptz.typing import AdapterConfig, DataArgument


def _run_coroutine(coro: Any) -> Any:
    """Run an async coroutine from synchronous code.

    Avoids ``asyncio.run()`` as it closes the event loop which can
    interfere with callers that still need their I/O handles (e.g.
    Click's ``CliRunner``).

    Args:
        coro: The awaitable coroutine to run.

    Returns:
        The coroutine result.
    """
    loop = asyncio.new_event_loop()
    try:
        return loop.run_until_complete(coro)
    finally:
        # Shut down async generators but do NOT call loop.close() —
        # that closes the selector and can invalidate I/O file
        # descriptors shared with the caller (e.g. Click CliRunner).
        try:
            loop.run_until_complete(loop.shutdown_asyncgens())
        except Exception:  # noqa: BLE001 — best-effort cleanup, cannot propagate here
            pass


def _load_env_file(env_file: str | None) -> None:
    """Load a ``.env`` file if it exists.

    Args:
        env_file: Path to the env file. ``None`` means try ``.env`` in cwd.
    """
    path = Path(env_file) if env_file else Path('.env')
    if path.is_file():
        load_dotenv(path, override=False)




# Model-name prefixes used to auto-infer a suitable adapter.
_MODEL_ADAPTER_PREFIXES: list[tuple[str, str]] = [
    ('gpt-', 'openai'),
    ('o1', 'openai'),
    ('o3', 'openai'),
    ('o4', 'openai'),
    ('chatgpt', 'openai'),
    ('claude', 'anthropic'),
    ('gemini', 'google'),
]

def _infer_adapter_from_model(model: str | None) -> str | None:
    """Guess the adapter name from the model identifier.

    Args:
        model: The model string from the prompt frontmatter.

    Returns:
        An adapter name, or ``None`` if it cannot be inferred.
    """
    if not model:
        return None
    lower = model.lower()
    for prefix, adapter_name in _MODEL_ADAPTER_PREFIXES:
        if lower.startswith(prefix):
            return adapter_name
    return None


def _resolve_adapter(rendered: Any) -> Any:
    """Resolve adapter from RenderedPrompt's adapter field.

    Handles three scenarios:
    1. AdapterConfig object: use name and base_url/api_key
    2. String adapter name: use directly
    3. None: auto-infer from config.model

    Args:
        rendered: The RenderedPrompt with adapter configuration.

    Returns:
        An Adapter instance ready to generate responses.

    Raises:
        click.UsageError: If no adapter can be determined.
    """
    adapter_cfg = rendered.adapter  # str | AdapterConfig | None
    adapter_name: str | None = None
    adapter_kwargs: dict[str, Any] = {}

    if isinstance(adapter_cfg, AdapterConfig):
        adapter_name = adapter_cfg.name
        if adapter_cfg.base_url:
            adapter_kwargs['base_url'] = adapter_cfg.base_url
        if adapter_cfg.api_key:
            adapter_kwargs['api_key'] = adapter_cfg.api_key
    elif isinstance(adapter_cfg, str):
        adapter_name = adapter_cfg
    else:
        # Auto-infer from config.model.
        config = rendered.config if isinstance(rendered.config, dict) else {}
        model_name = config.get('model')
        adapter_name = _infer_adapter_from_model(model_name)

    if not adapter_name:
        available = ', '.join(list_adapters())
        raise click.UsageError(
            'Cannot determine which adapter to use. '
            f'Add "adapter: <name>" to the .prompt frontmatter, '
            f'or use a model name that can be auto-detected. '
            f'Available adapters: {available}'
        )

    return get_adapter(adapter_name, **adapter_kwargs)


def _part_to_dict(part: Any) -> dict[str, Any]:
    """Convert a Part to a simple dict for JSON output.

    Args:
        part: A dotprompt Part object.

    Returns:
        A simplified dict representation.
    """
    from dotpromptz.typing import (
        DataPart,
        MediaPart,
        TextPart,
        ToolRequestPart,
        ToolResponsePart,
    )

    if isinstance(part, TextPart):
        return {'text': part.text}
    if isinstance(part, MediaPart):
        return {'media': part.media.model_dump(exclude_none=True, by_alias=True)}
    if isinstance(part, DataPart):
        return {'data': part.data}
    if isinstance(part, ToolRequestPart):
        return {'tool_request': part.tool_request.model_dump(exclude_none=True)}
    if isinstance(part, ToolResponsePart):
        return {'tool_response': part.tool_response.model_dump(exclude_none=True)}
    return {'type': type(part).__name__}


@click.command()
@click.argument('prompt_file', type=click.Path(exists=True, dir_okay=False))
@click.version_option(package_name='dotpromptz')
def cli(prompt_file: str) -> None:
    """Run a .prompt file with input configured in frontmatter.

    Auto-detects single vs batch mode based on input in frontmatter:
    - No input or dict input → single execution
    - List input → batch execution with concurrency control

    Batch execution settings (max_workers, output_dir) are read from
    the .prompt file's 'runtime:' section.

    PROMPT_FILE is the path to a .prompt file.

    Examples::

      runprompt explain.prompt
      runprompt batch_prompts.prompt
    """
    _run_coroutine(_run_cli_async(prompt_file=prompt_file))


async def _run_cli_async(*, prompt_file: str) -> None:
    """Async implementation of the unified CLI command."""
    from dotpromptz.parse import parse_document

    # 1. Load .env from cwd if present.
    _load_env_file(None)

    # 2. Read the prompt source and parse.
    prompt_path = Path(prompt_file)
    source = prompt_path.read_text(encoding='utf-8')
    parsed = parse_document(source)

    # 3. Resolve input data from frontmatter.
    try:
        records, is_batch = resolve_input(parsed.input.data if parsed.input else None, prompt_path)
    except ValueError as exc:
        raise click.ClickException(f'Input resolution failed: {exc}') from exc

    # 4. Auto-detect mode and execute.
    if not records:
        # Empty input → exit silently
        return
    elif is_batch:
        # Batch execution mode
        await _run_batch(source, records)
    else:
        # Single execution mode
        await _run_single(source, records[0])


async def _run_single(
    source: str,
    input_data: dict,
) -> None:
    """Execute single prompt rendering and generation.

    Args:
        source: The .prompt file content.
        input_data: Dict of input variables.
    """
    # 1. Create Dotprompt instance and render.
    dp = Dotprompt()
    rendered = dp.render(source, data=DataArgument(input=input_data))

    # 2. Resolve adapter.
    adapter = _resolve_adapter(rendered)

    # 3. Generate.
    try:
        response = await adapter.generate(rendered)
    except ImportError as exc:
        raise click.ClickException(str(exc)) from exc

    # 4. Output.
    if response.image:
        click.echo(f'Image saved to: {response.image.saved_path}')
    elif response.text:
        click.echo(response.text)
    elif response.tool_calls:
        click.echo(json.dumps([tc.model_dump() for tc in response.tool_calls], indent=2, ensure_ascii=False))


async def _run_batch(
    source: str,
    input_list: list[dict],
) -> None:
    """Execute batch prompt rendering and generation with concurrency control.

    Args:
        source: The .prompt file content.
        input_list: List of input variable dicts.
    """
    # 1. Create Dotprompt instance.
    dp = Dotprompt()

    # 2. Parse the prompt once to get runtime config.
    from dotpromptz.parse import parse_document

    parsed_prompt = parse_document(source)
    runtime_config = parsed_prompt.runtime  # RuntimeConfig | None

    # 3. Get batch settings from runtime config.
    max_workers = runtime_config.max_workers if runtime_config else 5
    output_dir = runtime_config.output_dir if runtime_config else None

    # 4. Render first item to resolve adapter.
    first_rendered = dp.render(source, data=DataArgument(input=input_list[0]))
    adapter = _resolve_adapter(first_rendered)

    # 5. Create output directory if needed (before concurrent tasks).
    if output_dir:
        Path(output_dir).mkdir(parents=True, exist_ok=True)

    # 6. Create semaphore for concurrency control.
    sem = asyncio.Semaphore(max_workers)

    # 7. Define task function to process one input.
    async def _process_one(index: int, variables: dict[str, Any]) -> dict[str, Any]:
        async with sem:
            try:
                # Render the prompt with this input set
                rendered = dp.render(source, data=DataArgument(input=variables))
                # Generate response
                response = await adapter.generate(rendered)
                return {
                    'index': index,
                    'input': variables,
                    'status': 'success',
                    'text': response.text,
                    'tool_calls': [tc.model_dump() for tc in response.tool_calls] if response.tool_calls else None,
                    'image': response.image.saved_path if response.image else None,
                    'usage': response.usage.model_dump(by_alias=True) if response.usage else None,
                    'model': response.model,
                    'finish_reason': response.finish_reason,
                    'error': None,
                }
            except Exception as exc:
                # Capture any error as a result
                return {
                    'index': index,
                    'input': variables,
                    'status': 'error',
                    'text': None,
                    'tool_calls': None,
                    'image': None,
                    'usage': None,
                    'model': None,
                    'finish_reason': None,
                    'error': str(exc),
                }

    # 8. Run all tasks concurrently with gather(return_exceptions=True)
    tasks = [_process_one(i, v) for i, v in enumerate(input_list)]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # 9. Handle any unexpected exceptions from gather
    final_results = []
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            # Unexpected exception that wasn't caught in _process_one
            final_results.append({
                'index': i,
                'input': input_list[i],
                'status': 'error',
                'text': None,
                'tool_calls': None,
                'image': None,
                'usage': None,
                'model': None,
                'finish_reason': None,
                'error': str(result),
            })
        else:
            final_results.append(result)

    # 10. Sort by index to maintain order
    final_results.sort(key=lambda r: r['index'])

    # 11. Output results
    if output_dir:
        # Write individual JSON files
        for result in final_results:
            filename = f'{result["index"]}.json'
            filepath = Path(output_dir) / filename
            filepath.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding='utf-8')
    # Always output to stdout as JSONL
    for result in final_results:
        click.echo(json.dumps(result, ensure_ascii=False))

    # Exit with error code if ALL items failed
    failed = sum(1 for r in final_results if r.get('status') == 'error')
    if failed == len(final_results):
        sys.exit(1)
