"""Core orchestrator: routes messages through command and conversation flows."""

from __future__ import annotations

import asyncio
import contextlib
import logging
import os
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING

from ductor_bot.cleanup import CleanupObserver
from ductor_bot.cli.codex_cache import CodexModelCache
from ductor_bot.cli.codex_cache_observer import CodexCacheObserver
from ductor_bot.cli.gemini_cache_observer import GeminiCacheObserver
from ductor_bot.cli.process_registry import ProcessRegistry
from ductor_bot.cli.service import CLIService, CLIServiceConfig
from ductor_bot.config import (
    _CLAUDE_MODELS,
    _GEMINI_ALIASES,
    AgentConfig,
    ModelRegistry,
    get_gemini_models,
    set_gemini_models,
)
from ductor_bot.cron.manager import CronManager
from ductor_bot.cron.observer import CronObserver
from ductor_bot.errors import (
    CLIError,
    CronError,
    SessionError,
    StreamError,
    WebhookError,
    WorkspaceError,
)
from ductor_bot.heartbeat import HeartbeatObserver
from ductor_bot.infra.docker import DockerManager
from ductor_bot.orchestrator.commands import (
    cmd_cron,
    cmd_diagnose,
    cmd_memory,
    cmd_model,
    cmd_reset,
    cmd_status,
    cmd_upgrade,
)
from ductor_bot.orchestrator.directives import parse_directives
from ductor_bot.orchestrator.flows import (
    heartbeat_flow,
    normal,
    normal_streaming,
)
from ductor_bot.orchestrator.hooks import MAINMEMORY_REMINDER, MessageHookRegistry
from ductor_bot.orchestrator.registry import CommandRegistry, OrchestratorResult
from ductor_bot.security import detect_suspicious_patterns
from ductor_bot.session import SessionManager
from ductor_bot.webhook.manager import WebhookManager
from ductor_bot.webhook.models import WebhookResult
from ductor_bot.webhook.observer import WebhookObserver
from ductor_bot.workspace.init import (
    init_workspace,
    inject_runtime_environment,
    watch_rule_files,
)
from ductor_bot.workspace.paths import DuctorPaths, resolve_paths
from ductor_bot.workspace.skill_sync import (
    cleanup_ductor_links,
    sync_bundled_skills,
    sync_skills,
    watch_skill_sync,
)

if TYPE_CHECKING:
    from ductor_bot.cli.auth import AuthResult, AuthStatus

logger = logging.getLogger(__name__)


_TextCallback = Callable[[str], Awaitable[None]]
_SystemStatusCallback = Callable[[str | None], Awaitable[None]]


@dataclass(slots=True)
class _MessageDispatch:
    """Normalized input for one orchestrator message routing pass."""

    chat_id: int
    text: str
    cmd: str
    streaming: bool = False
    on_text_delta: _TextCallback | None = None
    on_tool_activity: _TextCallback | None = None
    on_system_status: _SystemStatusCallback | None = None


def _docker_skill_resync(paths: DuctorPaths) -> None:
    """Re-run skill sync with copies so skills resolve inside Docker."""
    sync_bundled_skills(paths, docker_active=True)
    sync_skills(paths, docker_active=True)


class Orchestrator:
    """Routes messages through command dispatch and conversation flows."""

    def __init__(
        self,
        config: AgentConfig,
        paths: DuctorPaths,
        *,
        docker_container: str = "",
    ) -> None:
        self._config = config
        self._paths: DuctorPaths = paths
        self._docker: DockerManager | None = None
        self._models = ModelRegistry()
        self._known_model_ids: frozenset[str] = frozenset()
        self._refresh_known_model_ids()
        self._sessions = SessionManager(paths.sessions_path, config)
        self._process_registry = ProcessRegistry()
        self._available_providers: frozenset[str] = frozenset()
        self._cli_service = CLIService(
            config=CLIServiceConfig(
                working_dir=str(paths.workspace),
                default_model=config.model,
                provider=config.provider,
                max_turns=config.max_turns,
                max_budget_usd=config.max_budget_usd,
                permission_mode=config.permission_mode,
                reasoning_effort=config.reasoning_effort,
                gemini_api_key=config.gemini_api_key,
                docker_container=docker_container,
                claude_cli_parameters=tuple(config.cli_parameters.claude),
                codex_cli_parameters=tuple(config.cli_parameters.codex),
                gemini_cli_parameters=tuple(config.cli_parameters.gemini),
            ),
            models=self._models,
            available_providers=frozenset(),
            process_registry=self._process_registry,
        )
        self._cron_manager = CronManager(jobs_path=paths.cron_jobs_path)
        self._cron_observer: CronObserver | None = None  # Created in create() after cache init
        self._heartbeat = HeartbeatObserver(config)
        self._heartbeat.set_heartbeat_handler(self.handle_heartbeat)
        self._heartbeat.set_busy_check(self._process_registry.has_active)
        stale_max = config.cli_timeout * 2
        self._heartbeat.set_stale_cleanup(lambda: self._process_registry.kill_stale(stale_max))
        self._webhook_manager = WebhookManager(hooks_path=paths.webhooks_path)
        self._webhook_observer: WebhookObserver | None = (
            None  # Created in create() after cache init
        )
        self._cleanup_observer = CleanupObserver(config, paths)
        self._codex_cache_observer: CodexCacheObserver | None = None
        self._gemini_cache_observer: GeminiCacheObserver | None = None
        self._rule_sync_task: asyncio.Task[None] | None = None
        self._skill_sync_task: asyncio.Task[None] | None = None
        self._gemini_api_key_mode: bool | None = None
        self._hook_registry = MessageHookRegistry()
        self._hook_registry.register(MAINMEMORY_REMINDER)
        self._command_registry = CommandRegistry()
        self._register_commands()

    @property
    def paths(self) -> DuctorPaths:
        """Public access to resolved workspace paths."""
        return self._paths

    @classmethod
    async def create(cls, config: AgentConfig) -> Orchestrator:
        """Async factory: initialize workspace, build Orchestrator."""
        paths = resolve_paths(ductor_home=config.ductor_home)
        await asyncio.to_thread(init_workspace, paths)

        os.environ["DUCTOR_HOME"] = str(paths.ductor_home)

        docker_container = ""
        docker_mgr: DockerManager | None = None
        if config.docker.enabled:
            docker_mgr = DockerManager(config.docker, paths)
            container = await docker_mgr.setup()
            if container:
                docker_container = container
            else:
                logger.warning("Docker enabled but setup failed; running on host")

        if docker_container:
            await asyncio.to_thread(_docker_skill_resync, paths)

        await asyncio.to_thread(
            inject_runtime_environment, paths, docker_container=docker_container
        )

        orch = cls(config, paths, docker_container=docker_container)
        orch._docker = docker_mgr

        from ductor_bot.cli.auth import AuthStatus, check_all_auth

        auth_results = await asyncio.to_thread(check_all_auth)
        orch._apply_auth_results(auth_results, auth_status_enum=AuthStatus)

        if not orch._available_providers:
            logger.error("No authenticated providers found! CLI calls will fail.")
        else:
            logger.info("Available providers: %s", ", ".join(sorted(orch._available_providers)))

        await asyncio.to_thread(orch._init_gemini_state)

        safe_codex_cache = await orch._init_model_caches(paths)
        orch._cron_observer = CronObserver(
            paths,
            orch._cron_manager,
            config=config,
            codex_cache=safe_codex_cache,
        )
        orch._webhook_observer = WebhookObserver(
            paths,
            orch._webhook_manager,
            config=config,
            codex_cache=safe_codex_cache,
        )

        await orch._cron_observer.start()
        await orch._heartbeat.start()
        await orch._webhook_observer.start()
        await orch._cleanup_observer.start()
        orch._rule_sync_task = asyncio.create_task(watch_rule_files(paths.workspace))
        logger.info("Rule file watcher started (CLAUDE.md <-> AGENTS.md <-> GEMINI.md)")
        orch._skill_sync_task = asyncio.create_task(
            watch_skill_sync(paths, docker_active=bool(docker_container))
        )
        logger.info("Skill sync watcher started")

        return orch

    async def _init_model_caches(self, paths: DuctorPaths) -> CodexModelCache:
        """Start Gemini and Codex cache observers, return Codex cache."""
        # Gemini cache observer
        gemini_cache_path = paths.config_path.parent / "gemini_models.json"

        def _on_gemini_refresh(models: tuple[str, ...]) -> None:
            set_gemini_models(frozenset(models))
            self._refresh_known_model_ids()
            self._gemini_api_key_mode = None  # Invalidate to re-check on next access

        gemini_observer = GeminiCacheObserver(gemini_cache_path, on_refresh=_on_gemini_refresh)
        await gemini_observer.start()
        self._gemini_cache_observer = gemini_observer

        if not get_gemini_models():
            logger.warning("Gemini cache is empty after startup (Gemini may not be installed)")

        # Codex cache observer
        codex_cache_path = paths.config_path.parent / "codex_models.json"
        codex_observer = CodexCacheObserver(codex_cache_path)
        await codex_observer.start()
        self._codex_cache_observer = codex_observer
        codex_cache = codex_observer.get_cache()

        if not codex_cache or not codex_cache.models:
            logger.warning("Codex cache is empty after startup (Codex may not be authenticated)")

        return codex_cache or CodexModelCache("", [])

    def _refresh_known_model_ids(self) -> None:
        """Refresh directive-known model IDs from dynamic provider registries."""
        self._known_model_ids = _CLAUDE_MODELS | _GEMINI_ALIASES | get_gemini_models()

    def _apply_auth_results(
        self,
        auth_results: dict[str, AuthResult],
        *,
        auth_status_enum: type[AuthStatus],
    ) -> None:
        """Log provider auth states and update the runtime provider set."""
        authenticated = auth_status_enum.AUTHENTICATED
        installed = auth_status_enum.INSTALLED

        for provider, result in auth_results.items():
            if result.status == authenticated:
                logger.info("Provider [%s]: authenticated", provider)
            elif result.status == installed:
                logger.warning("Provider [%s]: installed but NOT authenticated", provider)
            else:
                logger.info("Provider [%s]: not found", provider)

        self._available_providers = frozenset(
            name for name, res in auth_results.items() if res.is_authenticated
        )
        self._cli_service.update_available_providers(self._available_providers)

    def _init_gemini_state(self) -> None:
        """Cache Gemini API-key mode and trust workspace once at startup."""
        from ductor_bot.cli.auth import gemini_uses_api_key_mode

        self._gemini_api_key_mode = gemini_uses_api_key_mode()
        if "gemini" in self._available_providers:
            from ductor_bot.cli.gemini_utils import trust_workspace

            trust_workspace(self._paths.workspace)

    @property
    def gemini_api_key_mode(self) -> bool:
        """Return cached Gemini API-key mode status."""
        if self._gemini_api_key_mode is None:
            from ductor_bot.cli.auth import gemini_uses_api_key_mode

            self._gemini_api_key_mode = gemini_uses_api_key_mode()
        return self._gemini_api_key_mode

    async def handle_message(self, chat_id: int, text: str) -> OrchestratorResult:
        """Main entry point: route message to appropriate handler."""
        dispatch = _MessageDispatch(chat_id=chat_id, text=text, cmd=text.strip().lower())
        return await self._handle_message_impl(dispatch)

    async def handle_message_streaming(
        self,
        chat_id: int,
        text: str,
        *,
        on_text_delta: _TextCallback | None = None,
        on_tool_activity: _TextCallback | None = None,
        on_system_status: _SystemStatusCallback | None = None,
    ) -> OrchestratorResult:
        """Main entry point with streaming support."""
        dispatch = _MessageDispatch(
            chat_id=chat_id,
            text=text,
            cmd=text.strip().lower(),
            streaming=True,
            on_text_delta=on_text_delta,
            on_tool_activity=on_tool_activity,
            on_system_status=on_system_status,
        )
        return await self._handle_message_impl(dispatch)

    async def _handle_message_impl(self, dispatch: _MessageDispatch) -> OrchestratorResult:
        self._process_registry.clear_abort(dispatch.chat_id)
        logger.info("Message received text=%s", dispatch.cmd[:80])

        patterns = detect_suspicious_patterns(dispatch.text)
        if patterns:
            logger.warning("Suspicious input patterns: %s", ", ".join(patterns))

        try:
            return await self._route_message(dispatch)
        except asyncio.CancelledError:
            raise
        except (CLIError, StreamError, SessionError, CronError, WebhookError, WorkspaceError):
            logger.exception("Domain error in handle_message")
            return OrchestratorResult(text="An internal error occurred. Please try again.")
        except (OSError, RuntimeError, ValueError, TypeError, KeyError):
            logger.exception("Unexpected error in handle_message")
            return OrchestratorResult(text="An internal error occurred. Please try again.")

    async def _route_message(self, dispatch: _MessageDispatch) -> OrchestratorResult:
        result = await self._command_registry.dispatch(
            dispatch.cmd,
            self,
            dispatch.chat_id,
            dispatch.text,
        )
        if result is not None:
            return result

        await self._ensure_docker()

        directives = parse_directives(dispatch.text, self._known_model_ids)

        if directives.is_directive_only and directives.has_model:
            return OrchestratorResult(
                text=f"Next message will use: {directives.model}\n"
                f"(Send a message with @{directives.model} <text> to use it.)",
            )

        prompt_text = directives.cleaned or dispatch.text

        if dispatch.streaming:
            return await normal_streaming(
                self,
                dispatch.chat_id,
                prompt_text,
                model_override=directives.model,
                on_text_delta=dispatch.on_text_delta,
                on_tool_activity=dispatch.on_tool_activity,
                on_system_status=dispatch.on_system_status,
            )

        return await normal(
            self,
            dispatch.chat_id,
            prompt_text,
            model_override=directives.model,
        )

    def _register_commands(self) -> None:
        reg = self._command_registry
        reg.register_async("/new", cmd_reset)
        # /stop is handled entirely by the Middleware abort path (before the lock)
        # and never reaches the orchestrator command registry.
        reg.register_async("/status", cmd_status)
        reg.register_async("/model", cmd_model)
        reg.register_async("/model ", cmd_model)
        reg.register_async("/memory", cmd_memory)
        reg.register_async("/cron", cmd_cron)
        reg.register_async("/diagnose", cmd_diagnose)
        reg.register_async("/upgrade", cmd_upgrade)

    async def reset_session(self, chat_id: int) -> None:
        """Reset the session for a given chat."""
        await self._sessions.reset_session(chat_id)
        logger.info("Session reset")

    async def reset_active_provider_session(self, chat_id: int) -> str:
        """Reset only the active provider session bucket for a given chat."""
        active = await self._sessions.get_active(chat_id)
        if active is not None:
            provider = active.provider
            model = active.model
        else:
            model, provider = self.resolve_runtime_target(self._config.model)

        await self._sessions.reset_provider_session(
            chat_id,
            provider=provider,
            model=model,
        )
        logger.info("Active provider session reset provider=%s", provider)
        return provider

    async def abort(self, chat_id: int) -> int:
        """Kill all active CLI processes for chat_id."""
        return await self._process_registry.kill_all(chat_id)

    def resolve_runtime_target(self, requested_model: str | None = None) -> tuple[str, str]:
        """Resolve requested model to the effective ``(model, provider)`` pair."""
        model_name = requested_model or self._config.model
        return model_name, self._models.provider_for(model_name)

    def set_cron_result_handler(
        self,
        handler: Callable[[str, str, str], Awaitable[None]],
    ) -> None:
        """Forward cron job results to an external handler (e.g. Telegram)."""
        if self._cron_observer:
            self._cron_observer.set_result_handler(handler)

    def set_heartbeat_handler(
        self,
        handler: Callable[[int, str], Awaitable[None]],
    ) -> None:
        """Forward heartbeat alert messages to an external handler (e.g. Telegram)."""
        self._heartbeat.set_result_handler(handler)

    async def handle_heartbeat(self, chat_id: int) -> str | None:
        """Run a heartbeat turn in the main session. Returns alert text or None."""
        logger.debug("Heartbeat flow starting")
        return await heartbeat_flow(self, chat_id)

    def set_webhook_result_handler(
        self,
        handler: Callable[[WebhookResult], Awaitable[None]],
    ) -> None:
        """Forward webhook results to an external handler (e.g. Telegram)."""
        if self._webhook_observer:
            self._webhook_observer.set_result_handler(handler)

    def set_webhook_wake_handler(
        self,
        handler: Callable[[int, str], Awaitable[str | None]],
    ) -> None:
        """Set the webhook wake handler (provided by the bot layer)."""
        if self._webhook_observer:
            self._webhook_observer.set_wake_handler(handler)

    @property
    def active_provider_name(self) -> str:
        """Human-readable name for the active CLI provider."""
        _model, provider = self.resolve_runtime_target(self._config.model)
        if provider == "claude":
            return "Claude Code"
        if provider == "gemini":
            return "Gemini"
        return "Codex"

    def is_chat_busy(self, chat_id: int) -> bool:
        """Check if a chat has active CLI processes."""
        return self._process_registry.has_active(chat_id)

    async def _ensure_docker(self) -> None:
        """Health-check Docker before CLI calls; auto-recover or fall back."""
        if not self._docker:
            return
        container = await self._docker.ensure_running()
        if container:
            self._cli_service.update_docker_container(container)
        elif self._cli_service._config.docker_container:
            logger.warning("Docker recovery failed, falling back to host execution")
            self._cli_service.update_docker_container("")

    async def shutdown(self) -> None:
        """Cleanup on bot shutdown."""
        for task in (self._rule_sync_task, self._skill_sync_task):
            if task and not task.done():
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task
        await asyncio.to_thread(cleanup_ductor_links, self._paths)
        await self._heartbeat.stop()
        if self._webhook_observer:
            await self._webhook_observer.stop()
        if self._cron_observer:
            await self._cron_observer.stop()
        await self._cleanup_observer.stop()
        if self._codex_cache_observer:
            await self._codex_cache_observer.stop()
            self._codex_cache_observer = None
        if self._gemini_cache_observer:
            await self._gemini_cache_observer.stop()
            self._gemini_cache_observer = None
        if self._docker:
            await self._docker.teardown()
        logger.info("Orchestrator shutdown")
