from __future__ import annotations

import shutil
import subprocess
import sys
from enum import Enum
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from typing import Annotated

import typer

from cli_demo import DEFAULT_MODEL, PACKAGE_NAME, __version__
from cli_demo.config import (
    ConfigError,
    get_effective_api_key,
    get_effective_base_url,
    get_config_path,
    get_effective_model,
    load_config,
    mask_secret,
    normalize_optional_value,
    save_config,
)
from cli_demo.openai_service import (
    AIServiceError,
    ChatMessage,
    EmptyPromptError,
    EmptyResponseError,
    MissingAPIKeyError,
    OpenAIRequestError,
    ask_model,
    chat_model,
    ping_model,
)

CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}

app = typer.Typer(
    help="An OpenAI-powered AI CLI built with Typer.",
    no_args_is_help=True,
    add_completion=False,
    context_settings=CONTEXT_SETTINGS,
)
config_app = typer.Typer(
    help="View and modify local CLI config.",
    context_settings=CONTEXT_SETTINGS,
)
app.add_typer(config_app, name="config")
CLEAR_SENTINEL = "__CLI_DEMO_CLEAR__"
AI_CLI_ERRORS = (
    ConfigError,
    AIServiceError,
)


class ConfigField(str, Enum):
    API_KEY = "api-key"
    BASE_URL = "base-url"
    MODEL = "model"


def _version_callback(value: bool) -> None:
    if value:
        try:
            config = load_config()
            effective_model = get_effective_model(config)
        except ConfigError:
            effective_model = DEFAULT_MODEL

        typer.echo(f"cli-demo {__version__} (model: {effective_model})")
        raise typer.Exit()


@app.callback()
def app_callback(
    version: Annotated[
        bool,
        typer.Option(
            "--version",
            "-v",
            help="Show the CLI version and effective model.",
            callback=lambda value: _version_callback(value),
            is_eager=True,
        ),
    ] = False,
) -> None:
    """Root callback for global CLI options."""


@app.command()
def ask(
    prompt: Annotated[
        str,
        typer.Argument(help="Prompt sent to the model."),
    ],
    model: Annotated[
        str | None,
        typer.Option(
            "--model",
            help="Optional model override for this request.",
        ),
    ] = None,
    system: Annotated[
        str | None,
        typer.Option(
            "--system",
            "-s",
            help="Optional system instruction for the model.",
        ),
    ] = None,
) -> None:
    """Send one prompt and print one model response."""

    try:
        output_text = ask_model(
            prompt=prompt,
            model=model,
            system_prompt=system,
        )
    except ConfigError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc
    except EmptyPromptError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc
    except MissingAPIKeyError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc
    except EmptyResponseError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc
    except OpenAIRequestError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc
    except Exception as exc:  # pragma: no cover - defensive fallback
        typer.secho(f"Internal error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc

    typer.echo(output_text)


@app.command()
def doctor(
    no_ping: Annotated[
        bool,
        typer.Option(
            "--no-ping",
            help="Skip the OpenAI API connectivity check.",
        ),
    ] = False,
    model: Annotated[
        str | None,
        typer.Option(
            "--model",
            help="Optional model override for the API connectivity check.",
        ),
    ] = None,
) -> None:
    """Check local config, dependencies, and optional API connectivity."""

    failures = 0

    def add_check(status: str, label: str, detail: str) -> None:
        nonlocal failures
        if status == "FAIL":
            failures += 1
        _echo_check(status=status, label=label, detail=detail)

    try:
        config = load_config()
        effective_model = get_effective_model(config, override=model)
    except ConfigError as exc:
        add_check("FAIL", "config", str(exc))
        raise typer.Exit(code=1) from exc

    config_path = get_config_path()
    if config_path.exists():
        add_check("OK", "config file", str(config_path))
    else:
        add_check(
            "WARN",
            "config file",
            f"not found at {config_path}; environment variables may still work",
        )

    api_key = get_effective_api_key(config)
    if api_key:
        source = "config file" if config.api_key else "environment"
        add_check("OK", "api key", f"{mask_secret(api_key)} ({source})")
    else:
        add_check(
            "FAIL",
            "api key",
            'not configured; run "cli-demo config set --api-key <your-api-key>" or set OPENAI_API_KEY',
        )

    base_url = get_effective_base_url(config)
    if base_url:
        source = "config file" if config.base_url else "environment"
        add_check("OK", "base url", f"{base_url} ({source})")
    else:
        add_check("OK", "base url", "default OpenAI endpoint")

    if model:
        model_source = "command override"
    elif config.model:
        model_source = "config file"
    else:
        model_source = "default"
    add_check("OK", "model", f"{effective_model} ({model_source})")

    uv_path = shutil.which("uv")
    if uv_path:
        add_check("OK", "uv", uv_path)
    else:
        add_check("WARN", "uv", "not found on PATH; the update command will not work")

    try:
        openai_version = package_version("openai")
    except PackageNotFoundError:
        add_check("FAIL", "openai package", "not installed")
    else:
        add_check("OK", "openai package", openai_version)

    if no_ping:
        add_check("WARN", "api ping", "skipped because --no-ping was used")
    elif api_key:
        try:
            output_text = ping_model(model=model)
        except AI_CLI_ERRORS as exc:
            add_check("FAIL", "api ping", str(exc))
        except Exception as exc:  # pragma: no cover - defensive fallback
            add_check("FAIL", "api ping", f"internal error: {exc}")
        else:
            add_check("OK", "api ping", f"received response: {output_text}")
    else:
        add_check("WARN", "api ping", "skipped because api key is missing")

    if failures:
        raise typer.Exit(code=1)


@app.command()
def chat(
    model: Annotated[
        str | None,
        typer.Option(
            "--model",
            help="Optional model override for this chat session.",
        ),
    ] = None,
    system: Annotated[
        str | None,
        typer.Option(
            "--system",
            "-s",
            help="Optional system instruction for this chat session.",
        ),
    ] = None,
) -> None:
    """Start an interactive multi-turn chat session."""

    try:
        effective_model = get_effective_model(load_config(), override=model)
        normalize_optional_value(system, field_name="system")
    except ConfigError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc

    history: list[ChatMessage] = []
    typer.echo(f"Starting chat with {effective_model}. Type /help for commands, /exit to quit.")

    while True:
        try:
            user_text = typer.prompt("You").strip()
        except (EOFError, KeyboardInterrupt):
            typer.echo("\nBye.")
            raise typer.Exit()

        command = user_text.lower()
        if command in {"/exit", "/quit"}:
            typer.echo("Bye.")
            return
        if command == "/help":
            _echo_chat_help()
            continue
        if command == "/clear":
            history.clear()
            typer.echo("Chat history cleared.")
            continue
        if not user_text:
            continue

        try:
            output_text = chat_model(
                history=history,
                prompt=user_text,
                model=model,
                system_prompt=system,
            )
        except AI_CLI_ERRORS as exc:
            typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
            raise typer.Exit(code=1) from exc
        except Exception as exc:  # pragma: no cover - defensive fallback
            typer.secho(f"Internal error: {exc}", err=True, fg=typer.colors.RED)
            raise typer.Exit(code=1) from exc

        history.append(ChatMessage(role="user", content=user_text))
        history.append(ChatMessage(role="assistant", content=output_text))
        typer.secho("Assistant:", fg=typer.colors.GREEN)
        typer.echo(output_text)


@app.command()
def update(
    dry_run: Annotated[
        bool,
        typer.Option(
            "--dry-run",
            "-n",
            help="Show the selected update command without running it.",
        ),
    ] = False,
) -> None:
    """Update this CLI with uv tool."""

    command = ["uv", "tool", "upgrade", PACKAGE_NAME]
    if not shutil.which(command[0]):
        typer.secho(
            f"Error: '{command[0]}' is not available on PATH.",
            err=True,
            fg=typer.colors.RED,
        )
        raise typer.Exit(code=1)

    typer.echo(f"Current version: {__version__}")
    typer.echo(f"Selected command: {' '.join(command)}")
    if dry_run:
        return

    completed = subprocess.run(command, check=False)
    if completed.returncode != 0:
        raise typer.Exit(code=completed.returncode)


@config_app.command("show")
def config_show() -> None:
    """Show current config values."""

    try:
        config = load_config()
        typer.echo(f"path: {get_config_path()}")
        typer.echo(f"api_key: {mask_secret(config.api_key)}")
        typer.echo(f"base_url: {config.base_url or '<not set>'}")
        typer.echo(f"model: {config.model or '<not set>'}")
        typer.echo(f"effective_model: {get_effective_model(config)}")
    except ConfigError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc


@config_app.command("path")
def config_path() -> None:
    """Print the config file path."""

    typer.echo(get_config_path())


@config_app.command("set")
def config_set(
    api_key: Annotated[
        str | None,
        typer.Option("--api-key", help="Set the OpenAI API key."),
    ] = None,
    base_url: Annotated[
        str | None,
        typer.Option("--base-url", help="Set the OpenAI base URL."),
    ] = None,
    model: Annotated[
        str | None,
        typer.Option("--model", help="Set the default model."),
    ] = None,
) -> None:
    """Update config values."""

    try:
        config = load_config()
        if api_key is None and base_url is None and model is None:
            typer.echo("Interactive config setup")
            typer.echo(f"path: {get_config_path()}")
            typer.echo("Leave blank to keep the current value. Type 'none' to clear a value.")

            api_key = _prompt_config_value(
                label="api_key",
                current_value=config.api_key,
                hide_input=True,
            )
            base_url = _prompt_config_value(
                label="base_url",
                current_value=config.base_url,
            )
            model = _prompt_config_value(
                label="model",
                current_value=config.model,
            )

        if api_key is not None:
            if api_key == CLEAR_SENTINEL:
                config.api_key = None
            else:
                config.api_key = normalize_optional_value(api_key, field_name="api_key")
        if base_url is not None:
            if base_url == CLEAR_SENTINEL:
                config.base_url = None
            else:
                config.base_url = normalize_optional_value(base_url, field_name="base_url")
        if model is not None:
            if model == CLEAR_SENTINEL:
                config.model = None
            else:
                config.model = normalize_optional_value(model, field_name="model")

        path = save_config(config)
        typer.echo(f"Updated config: {path}")
    except ConfigError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc


@config_app.command("unset")
def config_unset(
    field: Annotated[
        ConfigField,
        typer.Argument(help="Config field to clear."),
    ],
) -> None:
    """Clear one config value."""

    try:
        config = load_config()
        if field is ConfigField.API_KEY:
            config.api_key = None
        elif field is ConfigField.BASE_URL:
            config.base_url = None
        else:
            config.model = None

        path = save_config(config)
        typer.echo(f"Updated config: {path}")
    except ConfigError as exc:
        typer.secho(f"Error: {exc}", err=True, fg=typer.colors.RED)
        raise typer.Exit(code=1) from exc


def main() -> None:
    app()


def _prompt_config_value(
    *,
    label: str,
    current_value: str | None,
    hide_input: bool = False,
) -> str | None:
    display_current = mask_secret(current_value) if hide_input else (current_value or "<not set>")
    typer.echo(f"Current {label}: {display_current}")
    raw_value = typer.prompt(
        f"New {label}",
        default="",
        show_default=False,
        hide_input=hide_input and sys.stdin.isatty(),
    ).strip()

    if raw_value == "":
        return None
    if raw_value.lower() == "none":
        return CLEAR_SENTINEL
    return raw_value


def _echo_check(*, status: str, label: str, detail: str) -> None:
    colors = {
        "OK": typer.colors.GREEN,
        "WARN": typer.colors.YELLOW,
        "FAIL": typer.colors.RED,
    }
    color = colors.get(status, typer.colors.WHITE)
    typer.secho(f"[{status}] {label}: ", fg=color, nl=False)
    typer.echo(detail)


def _echo_chat_help() -> None:
    typer.echo("Chat commands:")
    typer.echo("  /help   Show this help message.")
    typer.echo("  /clear  Clear in-memory chat history.")
    typer.echo("  /exit   Quit the chat session.")
    typer.echo("  /quit   Quit the chat session.")
