import asyncio
import os
import ssl
import sys
from contextlib import suppress
from pathlib import Path
from typing import override

import clypi
from clypi import Command

from otto.config import (
    CONFIG_FILE,
    OTTO_HOME,
    AgentConfig,
    Config,
    ConfigError,
    TelegramConfig,
    WorkspaceConfig,
    load_config,
)

ENV_FILE = OTTO_HOME / ".env"
DEFAULT_MODEL = "anthropic/claude-sonnet-4-5-20250514"
_TLS_CERT_BUNDLE_CONFIGURED = False

VALID_AUTH_PROVIDERS = ("anthropic", "google", "openai", "copilot")


# === Setup Helpers ===


def _run_sync_daemon_command(name: str) -> bool:
    try:
        import otto.daemon as daemon_module
    except Exception:
        return False

    daemon_fn = getattr(daemon_module, name, None)
    if daemon_fn is None:
        return False

    daemon_fn()
    return True


def _default_web_config() -> Config:
    return Config(
        telegram=TelegramConfig(
            token="",
            owner_id=None,
            allowed_users=[],
            bootstrap="first_user",
            bots=[],
        ),
        agent=AgentConfig(model=DEFAULT_MODEL),
        log_level="info",
        workdir=Path("~").expanduser(),
        env_file=None,
        workspace=WorkspaceConfig(root=Path("~").expanduser()),
    )


def _mask_secret(secret: str) -> str:
    if len(secret) <= 8:
        return "***"
    return f"{secret[:4]}...{secret[-4:]}"


def _masked_credential_detail(creds: dict[str, object]) -> str:
    for key in ("access", "key", "refresh"):
        value = creds.get(key)
        if isinstance(value, str) and value:
            return f"{key}={_mask_secret(value)}"
    return "no token fields"


def _configure_tls_cert_bundle() -> None:
    """Prefer system CA bundle when SSL_CERT_FILE is unset."""
    global _TLS_CERT_BUNDLE_CONFIGURED
    if _TLS_CERT_BUNDLE_CONFIGURED:
        return

    if not os.getenv("SSL_CERT_FILE"):
        with suppress(Exception):
            default_paths = ssl.get_default_verify_paths()
            cafile = default_paths.cafile
            if cafile and Path(cafile).is_file():
                os.environ.setdefault("SSL_CERT_FILE", cafile)
                os.environ.setdefault("REQUESTS_CA_BUNDLE", cafile)

    _TLS_CERT_BUNDLE_CONFIGURED = True


def _print_config_error(exc: ConfigError) -> None:
    message = str(exc)
    print(f"Failed to load config: {message}", file=sys.stderr)
    if "Environment variable" in message:
        print(f"  → Add missing env vars to {ENV_FILE} (or export in your shell)", file=sys.stderr)
        print(f'  → Keep `env_file = ".env"` at top of {CONFIG_FILE}', file=sys.stderr)


def _load_config_or_report() -> Config | None:
    try:
        config = load_config()
    except ConfigError as exc:
        _print_config_error(exc)
        return None

    _configure_tls_cert_bundle()
    return config


# === Commands ===


async def _run_web_setup() -> bool:
    """Launch the web wizard and wait for config file creation.

    Returns True if setup completed, False if cancelled.
    """
    import webbrowser

    from otto.web import ConfigServer

    config = _default_web_config()
    host = "127.0.0.1"
    port = config.web.port
    server = ConfigServer(config=config, host=host, port=port)

    try:
        await server.start()
    except OSError as exc:
        if exc.errno == 98 or "address already in use" in str(exc).lower():
            print(f"Failed to start setup server: port {port} is already in use.", file=sys.stderr)
            return False
        print(f"Failed to start setup server: {exc}", file=sys.stderr)
        return False

    url = f"http://{host}:{port}"
    print(f"Otto setup: {url}")
    print("Complete the wizard in your browser. Press Ctrl+C to cancel.")
    with suppress(Exception):
        webbrowser.open(url)

    # Wait for config file to appear (created by the web wizard POST /setup).
    try:
        while not CONFIG_FILE.exists():
            await asyncio.sleep(0.5)
    except (KeyboardInterrupt, asyncio.CancelledError):
        print("\nSetup cancelled.")
        await server.stop()
        return False

    await server.stop()
    print("Setup complete!")
    return True


async def _run_cli_setup() -> bool:
    """Run the terminal-based setup wizard.

    Returns True if setup completed, False if cancelled.
    """
    from otto.setup_core.apply import apply_plan
    from otto.setup_ui_cli import run_setup

    plan = run_setup()
    if plan is None:
        return False

    apply_plan(plan, config_path=CONFIG_FILE, env_file=ENV_FILE)
    return True


class Setup(Command):
    """Set up Otto via web wizard (or --headless for terminal)."""

    headless: bool = clypi.arg(default=False, help="Use terminal setup instead of browser wizard")

    @override
    async def run(self) -> None:
        if CONFIG_FILE.exists():
            overwrite = clypi.confirm("Config exists. Overwrite?", default=False)
            if not overwrite:
                print("Setup cancelled.")
                return
            # Remove old config so the web wizard is shown instead of the dashboard.
            CONFIG_FILE.unlink()

        if self.headless:
            ok = await _run_cli_setup()
        else:
            ok = await _run_web_setup()

        if not ok:
            return

        start_now = clypi.confirm("Start Otto now?", default=True)
        if start_now:
            _spawn_background()


# === Daemon Control ===


def _spawn_background(bot_alias: str | None = None) -> None:
    """Launch 'otto run' as a detached background process."""
    import subprocess
    import sys
    import time

    from otto.daemon import _pid_file, is_running

    if is_running(bot_alias):
        target = f"Otto ({bot_alias})" if bot_alias else "Otto"
        print(f"{target} is already running. Use 'otto status' or 'otto stop'.")
        return

    log_dir = OTTO_HOME / "logs"
    log_dir.mkdir(parents=True, exist_ok=True)
    crash_log = log_dir / (f"otto-{bot_alias}-crash.log" if bot_alias else "otto-crash.log")
    pid_file = _pid_file(bot_alias)

    # Remove stale PID file so we can detect fresh startup
    pid_file.unlink(missing_ok=True)

    crash_fh = open(crash_log, "w")  # noqa: SIM115
    args = [sys.executable, "-m", "otto.cli", "run"]
    if bot_alias:
        args.append(bot_alias)

    proc = subprocess.Popen(
        args,
        start_new_session=True,
        stdout=subprocess.DEVNULL,
        stdin=subprocess.DEVNULL,
        stderr=crash_fh,
    )

    # Wait for PID file (written after bot starts polling) or process death
    for _ in range(10):
        time.sleep(1)
        if pid_file.exists():
            crash_fh.close()
            crash_log.unlink(missing_ok=True)
            target = f"Otto ({bot_alias})" if bot_alias else "Otto"
            log_file = log_dir / (f"otto.{bot_alias}.log" if bot_alias else "otto.log")
            print(f"{target} started (PID {proc.pid}). Logs: {log_file}")
            print("Use 'otto status' to check, 'otto stop' to stop.")
            return
        if proc.poll() is not None:
            break

    crash_fh.close()
    crash_output = crash_log.read_text(encoding="utf-8").strip()
    print("Otto failed to start.")
    if crash_output:
        print(crash_output)
    else:
        log_file = log_dir / (f"otto.{bot_alias}.log" if bot_alias else "otto.log")
        print(f"Check logs: {log_file}")


class Start(Command):
    """Start Otto in the background."""

    bot: clypi.Positional[str] = clypi.arg(default="", help="Bot alias to start")

    @override
    async def run(self) -> None:
        if not CONFIG_FILE.exists():
            if not await _run_web_setup():
                return
        _spawn_background(self.bot or None)


class Run(Command):
    """Run Otto services in the foreground (used internally)."""

    bot: clypi.Positional[str] = clypi.arg(default="", help="Bot alias to run")

    @override
    async def run(self) -> None:
        from otto.daemon import start_services

        if not CONFIG_FILE.exists():
            if not await _run_web_setup():
                return

        config = _load_config_or_report()
        if config is None:
            return

        try:
            await start_services(config, bot_alias=self.bot or None)
        except RuntimeError as exc:
            message = str(exc)
            if "already running" in message.lower():
                print(message, file=sys.stderr)
                return
            if "invalid telegram bot token" in message.lower():
                print(f"Error: {message}", file=sys.stderr)
                print(
                    "  → Set the real token in ~/.otto/.env and reference it in config.toml",
                    file=sys.stderr,
                )
                print('  →   token = "${TELEGRAM_BOT_TOKEN}"', file=sys.stderr)
                print('  →   env_file = ".env"  (top-level in config.toml)', file=sys.stderr)
                return
            raise


class Stop(Command):
    """Stop Otto services."""

    bot: clypi.Positional[str] = clypi.arg(default="", help="Bot alias to stop")

    @override
    async def run(self) -> None:
        from otto.daemon import stop_daemon

        stop_daemon(self.bot or None)


class Restart(Command):
    """Stop Otto, wait until fully dead, then start again."""

    bot: clypi.Positional[str] = clypi.arg(default="", help="Bot alias to restart")

    @override
    async def run(self) -> None:
        from otto.daemon import _discover_run_pids, _pid_file, _read_pid, stop_daemon, wait_for_dead

        if _load_config_or_report() is None:
            return

        bot_alias = self.bot or None

        # Collect all pids *before* sending SIGTERM so we can wait on them
        pid_file = _pid_file(bot_alias)
        pids: set[int] = set(_discover_run_pids(bot_alias))
        tracked = _read_pid(pid_file)
        if tracked is not None:
            pids.add(tracked)

        if not pids:
            target = f"Otto ({bot_alias})" if bot_alias else "Otto"
            print(f"{target} is not running — starting fresh.")
        else:
            stop_daemon(bot_alias)
            target = f"Otto ({bot_alias})" if bot_alias else "Otto"
            print(f"Waiting for {target} to stop...")
            await asyncio.to_thread(wait_for_dead, pids)
            print("Stopped.")

        _spawn_background(bot_alias)


# === Status Display ===


def _print_status_box(config: Config) -> None:
    """Print a status box with config and service state."""
    from otto.daemon import _pid_file, is_running

    provider = config.agent.model.split("/", 1)[0] if "/" in config.agent.model else "unknown"

    # Main bot status
    main_running = is_running("main") or is_running(None)
    main_pid = ""
    if main_running:
        pf = _pid_file("main")
        if not pf.exists():
            pf = _pid_file(None)
        if pf.exists():
            with suppress(Exception):
                main_pid = pf.read_text(encoding="utf-8").strip()

    status_icon = "● running" if main_running else "○ stopped"

    w = 52
    bar = "─" * w
    print(f"\n  ┌{bar}┐")
    print(f"  │{'Otto':^{w}}│")
    print(f"  ├{bar}┤")
    if main_running:
        print(f"  │  {'Status:':<14}{status_icon + ' (PID ' + main_pid + ')':<{w - 16}}│")
    else:
        print(f"  │  {'Status:':<14}{status_icon:<{w - 16}}│")

    # Other bots
    if config.telegram is not None:
        bot_aliases = [bot.alias for bot in config.telegram.bots]
    else:
        bot_aliases = [bot.name for bot in config.bots]
    if bot_aliases:
        print(f"  ├{bar}┤")
        print(f"  │  {'Bots:':<{w - 4}}│")

        # Show main bot first
        main_running = is_running("main") or is_running(None)
        main_icon = "●" if main_running else "○"
        main_pid_text = ""
        if main_running:
            pf = _pid_file("main")
            if not pf.exists():
                pf = _pid_file(None)
            if pf.exists():
                with suppress(Exception):
                    main_pid_text = f" (PID {pf.read_text(encoding='utf-8').strip()})"
        print(f"  │    {main_icon} {'main':<10}{main_pid_text:<{w - 19}}│")

        for alias in bot_aliases:
            if alias in ("main", "default"):
                continue
            running = is_running(alias)
            icon = "●" if running else "○"
            pid = ""
            if running:
                with suppress(Exception):
                    pid = f" (PID {_pid_file(alias).read_text(encoding='utf-8').strip()})"
            print(f"  │    {icon} {alias:<10}{pid:<{w - 19}}│")

    print(f"  ├{bar}┤")
    print(f"  │  {'Model:':<14}{config.agent.model:<{w - 16}}│")
    print(f"  │  {'Provider:':<14}{provider:<{w - 16}}│")
    print(f"  │  {'Log level:':<14}{config.log_level:<{w - 16}}│")
    print(f"  │  {'Config:':<14}{str(CONFIG_FILE):<{w - 16}}│")
    print(f"  │  {'Logs:':<14}{str(OTTO_HOME / 'logs' / 'otto.log'):<{w - 16}}│")
    if config.telegram is not None:
        oid = config.telegram.owner_id
        bootstrap = config.telegram.bootstrap
    else:
        owner_user = config.users[0] if config.users else None
        oid = owner_user.telegram_id if owner_user else None
        bootstrap = config.bots[0].auth.bootstrap if config.bots else "disabled"

    if oid:
        print(f"  │  {'Owner ID:':<14}{str(oid):<{w - 16}}│")
    else:
        print(f"  │  {'Bootstrap:':<14}{bootstrap:<{w - 16}}│")

    if not main_running:
        print(f"  ├{bar}┤")
        print(f"  │  {'Run: otto start':<{w - 2}}│")
    else:
        print(f"  ├{bar}┤")
        print(f"  │  {'otto stop':<16}{'Stop Otto':<{w - 18}}│")
        print(f"  │  {'otto config':<16}{'Show raw configuration':<{w - 18}}│")
    print(f"  └{bar}┘\n")


class Status(Command):
    """Show Otto service status and configuration."""

    @override
    async def run(self) -> None:
        config = _load_config_or_report()
        if config is None:
            return

        _print_status_box(config)


class ConfigCmd(Command):
    """Show raw configuration values."""

    @override
    async def run(self) -> None:
        config = _load_config_or_report()
        if config is None:
            return

        print("Current configuration:")
        print(f"  env_file = {config.env_file}")
        print(f"  log_level = {config.log_level}")
        print(f"  workdir = {config.workdir}")
        print(f"  workspace.root = {config.workspace.root}")
        print(f"  workspace.mode = {config.workspace.mode}")
        print(f"  workspace.sandbox = {config.workspace.sandbox}")
        print(f"  workspace.sandbox_network = {config.workspace.sandbox_network}")
        print(f"  agent.model = {config.agent.model}")
        if config.telegram is not None:
            print(f"  telegram.token = {config.telegram.token}")
            print(f"  telegram.owner_id = {config.telegram.owner_id}")
            print(f"  telegram.allowed_users = {config.telegram.allowed_users}")
            print(f"  telegram.bootstrap = {config.telegram.bootstrap}")
            for index, bot in enumerate(config.telegram.bots):
                print(f"  telegram.bots[{index}].alias = {bot.alias}")
        elif config.bots:
            bot = config.bots[0]
            bot_token = ""
            for channel in bot.channels:
                if channel.type == "telegram" and channel.token is not None:
                    bot_token = channel.token
                    break
            print(f"  telegram.token = {bot_token}")
            owner_id = config.users[0].telegram_id if config.users else None
            print(f"  telegram.owner_id = {owner_id}")
            print(f"  telegram.allowed_users = {bot.auth.allowed_users}")
            print(f"  telegram.bootstrap = {bot.auth.bootstrap}")
            for index, bot_item in enumerate(config.bots):
                print(f"  telegram.bots[{index}].alias = {bot_item.name}")


class Logs(Command):
    """Stream Otto logs in human-readable format."""

    follow: bool = clypi.arg(default=False, short="-f", help="Follow log output")
    lines: int = clypi.arg(default=50, short="-n", help="Number of recent lines to show")
    level: str = clypi.arg(default="", short="-l", help="Minimum level (debug/info/warning/error)")

    @override
    async def run(self) -> None:
        from otto.logs_view import render_logs

        log_file = OTTO_HOME / "logs" / "otto.log"
        if not log_file.exists():
            print("No log file found. Is Otto running?")
            print(f"  Expected: {log_file}")
            return

        await render_logs(
            log_file,
            follow=self.follow,
            tail=self.lines,
            level_filter=self.level.upper() if self.level else None,
        )


class AuthLogin(Command):
    """Authenticate with an LLM provider via OAuth."""

    provider: clypi.Positional[str] = clypi.arg(
        help="Provider: anthropic, google, openai, or copilot"
    )
    no_browser: bool = clypi.arg(
        default=False, help="Skip browser and local callback; prompt to paste the code"
    )

    @classmethod
    def prog(cls) -> str:
        return "login"

    @override
    async def run(self) -> None:
        import importlib
        import inspect
        import webbrowser

        from otto.auth import AuthStorage

        _configure_tls_cert_bundle()

        provider = self.provider.strip().lower()
        if provider not in VALID_AUTH_PROVIDERS:
            print(f"Unknown provider '{provider}'. Choose from: {', '.join(VALID_AUTH_PROVIDERS)}")
            raise SystemExit(1)

        try:
            auth_module = importlib.import_module(f"otto.auth.{provider}")
        except ImportError:
            print(f"OAuth login flow for '{provider}' is not available in this build.")
            raise SystemExit(1) from None

        login_fn = getattr(auth_module, "login", None)
        if not callable(login_fn):
            print(f"Provider '{provider}' does not expose a login() function.")
            raise SystemExit(1)

        def _on_auth_url(url: str, extra: str = "") -> None:
            if extra:
                print(extra)
            print(f"Open this URL in your browser: {url}")
            if not self.no_browser:
                with suppress(Exception):
                    webbrowser.open(url)

        def _on_progress(message: str) -> None:
            if message:
                print(message)

        async def _on_prompt_code() -> str:
            return clypi.prompt("Paste the authorization code").strip()

        _prompt_supports_show_default = "show_default" in inspect.signature(clypi.prompt).parameters

        async def _on_prompt(data: dict) -> object:
            prompt = str(data.get("message", "Enter value"))
            allow_empty = bool(data.get("allowEmpty", False))
            if allow_empty:
                prompt_kwargs = {"default": ""}
                if _prompt_supports_show_default:
                    prompt_kwargs["show_default"] = False
                return clypi.prompt(prompt, **prompt_kwargs)
            return clypi.prompt(prompt)

        callbacks: dict[str, object] = {
            "on_auth_url": _on_auth_url,
            "on_prompt_code": _on_prompt_code,
            "on_progress": _on_progress,
            "on_prompt": _on_prompt,
            "no_browser": self.no_browser,
        }

        try:
            signature = inspect.signature(login_fn)
            supports_kwargs = any(
                parameter.kind is inspect.Parameter.VAR_KEYWORD
                for parameter in signature.parameters.values()
            )
            if supports_kwargs:
                login_kwargs = callbacks
            else:
                login_kwargs = {
                    name: callback
                    for name, callback in callbacks.items()
                    if name in signature.parameters
                }

            credentials_result = login_fn(**login_kwargs)
            credentials = (
                await credentials_result
                if inspect.isawaitable(credentials_result)
                else credentials_result
            )
        except Exception as exc:
            print(f"Authentication failed for {provider}: {exc}")
            raise SystemExit(1) from exc

        if not isinstance(credentials, dict):
            print(f"Authentication failed for {provider}: invalid credentials payload")
            raise SystemExit(1)

        if provider == "copilot":
            storage = AuthStorage()
            if storage.get_credentials("copilot") is not None:
                print(
                    "Legacy Copilot credentials in Otto storage are now ignored; "
                    "LiteLLM manages Copilot tokens."
                )
            print(
                "Authenticated with copilot. Credentials are managed by LiteLLM "
                "(~/.config/litellm/github_copilot)."
            )
            return

        storage = AuthStorage()
        storage.save_credentials(provider, credentials)
        print(f"Authenticated with {provider}.")


class AuthLogout(Command):
    """Remove stored credentials for a provider."""

    provider: clypi.Positional[str] = clypi.arg(help="Provider to log out from")

    @classmethod
    def prog(cls) -> str:
        return "logout"

    @override
    async def run(self) -> None:
        from otto.auth import AuthStorage

        provider = self.provider.strip().lower()
        if provider not in VALID_AUTH_PROVIDERS:
            print(f"Unknown provider '{provider}'. Choose from: {', '.join(VALID_AUTH_PROVIDERS)}")
            raise SystemExit(1)

        storage = AuthStorage()
        existed = storage.get_credentials(provider) is not None
        storage.delete_credentials(provider)
        if existed:
            print(f"Logged out of {provider}.")
        else:
            print(f"No stored credentials for {provider}.")


class AuthStatus(Command):
    """Show stored provider credentials."""

    @classmethod
    def prog(cls) -> str:
        return "status"

    @override
    async def run(self) -> None:
        import json

        from otto.auth import AUTH_FILE, AuthStorage

        storage = AuthStorage()
        if not AUTH_FILE.exists():
            print("No stored credentials.")
            return

        try:
            payload = json.loads(AUTH_FILE.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            print("No stored credentials.")
            return

        credentials_map = payload.get("credentials")
        if not isinstance(credentials_map, dict) or not credentials_map:
            print("No stored credentials.")
            return

        print("Stored credentials:")
        for provider in sorted(credentials_map):
            credentials = storage.get_credentials(provider)
            if not isinstance(credentials, dict):
                continue

            cred_type = str(credentials.get("type", "unknown"))
            print(f"  {provider}: {cred_type} ({_masked_credential_detail(credentials)})")


class Auth(Command):
    """Authentication and credential management."""

    subcommand: AuthLogin | AuthLogout | AuthStatus | None = None


# === Entry Point ===


async def _standalone_cli(config: Config, resume_id: str | None = None) -> None:
    """Run a CLI chat session without starting the daemon.

    Used when the daemon is already running (e.g. via ``otto start``).
    Constructs minimal per-bot resources and runs the CLI channel.
    """
    from otto.chat import Chat
    from otto.cli_chat import CLIChatChannel
    from otto.config import BotAuthConfig, BotConfig, ChannelConfig
    from otto.gateway import Gateway, Policy
    from otto.log import setup as log_setup
    from otto.memory import Memory
    from otto.sessions import SessionStore
    from otto.tools import create_tools

    log_file = OTTO_HOME / "logs" / "otto.log"
    log_setup(level=config.log_level, log_file=log_file, console=False)

    # Resolve which bot to use.
    if getattr(config, "bots", None) and config.bots:
        target = next(
            (b for b in config.bots if any(c.type == "cli" for c in b.channels)),
            config.bots[0],
        )
    else:
        target = BotConfig(
            name="default",
            model=config.agent.model,
            auth=BotAuthConfig(owner="cli", allowed_users=[]),
            workspace=config.workspace,
        )

    bot_home = OTTO_HOME / "bots" / target.name
    bot_home.mkdir(parents=True, exist_ok=True)

    memory = Memory(bot_home / "data" / "memory.db")
    session_store = SessionStore(bot_home / "sessions")
    gateway = Gateway(Policy(blocked_paths=[], max_calls_per_session=0, allowed_servers=None))
    gateway.add_tools(create_tools(memory=memory))

    chat = Chat(config, gateway, session_store, memory, bot_name=target.name)
    channel = CLIChatChannel(chat, target, ChannelConfig(type="cli"), resume_id=resume_id)

    try:
        await channel.start()
    finally:
        memory.close()


class ChatCmd(Command):
    """Start an interactive CLI chat session."""

    resume: str = clypi.arg(default="", help="Resume archived session by timestamp")

    @classmethod
    def prog(cls) -> str:
        return "chat"

    @override
    async def run(self) -> None:
        if not CONFIG_FILE.exists():
            if not await _run_web_setup():
                return
            config = load_config(CONFIG_FILE)
        else:
            config = load_config(CONFIG_FILE)
        _configure_tls_cert_bundle()
        resume_id = self.resume or None

        from otto.daemon import start_services

        daemon_already_running = False
        try:
            await start_services(config, cli_bot="auto", cli_resume_id=resume_id)
        except RuntimeError as exc:
            if "already running" not in str(exc):
                raise
            daemon_already_running = True

        if daemon_already_running:
            # Daemon is running — start a standalone CLI session.
            await _standalone_cli(config, resume_id=resume_id)


class Otto(Command):
    """Otto — AI agent platform."""

    subcommand: (
        ChatCmd | Setup | Start | Run | Stop | Restart | Status | ConfigCmd | Logs | Auth | None
    ) = None


def main() -> None:
    cmd = Otto.parse()
    try:
        cmd.start()
    except KeyboardInterrupt:
        # Keep Ctrl+C exits quiet for interactive commands (e.g. `otto chat`).
        # asyncio.run() raises KeyboardInterrupt from the event loop boundary,
        # which would otherwise print a full traceback.
        print("exiting...")
        return


if __name__ == "__main__":
    main()
