from __future__ import annotations

import itertools
import logging
from collections import defaultdict
from typing import Callable, Iterable
from weakref import WeakKeyDictionary

from ulauncher.internals import effect_utils, effects
from ulauncher.internals.query import Query
from ulauncher.internals.query_history import QueryHistory
from ulauncher.internals.result import ActionResult, KeywordTrigger, Result
from ulauncher.internals.result_buffer import ResultBuffer
from ulauncher.internals.results_update import ResultsUpdate, results_update
from ulauncher.modes.mode import Mode
from ulauncher.utils import scheduling
from ulauncher.utils.eventbus import EventBus
from ulauncher.utils.lru_cache import lru_cache
from ulauncher.utils.settings import Settings

_events = EventBus()
logger = logging.getLogger(__name__)

PLACEHOLDER_DELAY = 0.3  # delay in sec before Loading... is rendered

ResultsCallback = Callable[[ResultsUpdate], None]


@lru_cache(maxsize=None)
def get_app_mode() -> Mode:
    from ulauncher.modes.apps.app_mode import AppMode

    return AppMode()


@lru_cache(maxsize=None)
def get_modes() -> list[Mode]:
    from ulauncher.modes.calc.calc_mode import CalcMode
    from ulauncher.modes.extensions.extension_mode import ExtensionMode
    from ulauncher.modes.file_browser.file_browser_mode import FileBrowserMode
    from ulauncher.modes.shortcuts.shortcut_mode import ShortcutMode

    return [FileBrowserMode(), CalcMode(), ShortcutMode(), ExtensionMode(), get_app_mode()]


class UlauncherCore:
    """Core application logic to handle the query events and delegate them to the modes."""

    _mode: Mode | None = None
    _keyword_cache: defaultdict[Mode, dict[str, Result]] = defaultdict(dict)
    _trigger_cache: defaultdict[Mode, list[Result]] = defaultdict(list)
    _mode_map: WeakKeyDictionary[Result, Mode] = WeakKeyDictionary()
    query: Query = Query(None, "")
    _placeholder_timer: scheduling.Context | None = None

    def __init__(self) -> None:
        self._result_buffer = ResultBuffer()

    @property
    def last_query_result_pick(self) -> str | None:
        """Name of the result the user last activated for the current query, if any."""
        return QueryHistory.load().get(str(self.query))

    def load_triggers(self, force: bool = False) -> None:
        """Load or refresh triggers from modes that have changes."""
        claimed_keywords: dict[str, Result] = {}
        outdated_modes: set[Mode] = set()

        # load claimed keywords and modes that need to be refreshed
        for mode in get_modes():
            if force or mode.has_trigger_changes():
                outdated_modes.add(mode)
            else:
                claimed_keywords.update(self._keyword_cache[mode])

        for mode in outdated_modes:
            triggers = self._trigger_cache[mode]
            keywords = self._keyword_cache[mode]
            triggers.clear()
            keywords.clear()

            for trigger in mode.get_triggers():
                triggers.append(trigger)
                self._mode_map[trigger] = mode
                if trigger.keyword:
                    if trigger.keyword not in claimed_keywords:
                        keywords[trigger.keyword] = trigger
                        claimed_keywords[trigger.keyword] = trigger
                    else:
                        current_trigger = claimed_keywords[trigger.keyword]
                        logger.warning(
                            'Cannot register keyword "%s" for "%s" (%s). It is already used by "%s" (%s).',
                            trigger.keyword,
                            trigger.name,
                            trigger.__class__.__name__,
                            current_trigger.name,
                            current_trigger.__class__.__name__,
                        )

    def set_query(self, query_str: str, callback: ResultsCallback) -> None:
        """Set the query string and propagate the update to the modes."""
        if not query_str:
            self._mode = None
            self.query = Query(None, "")
            self._result_buffer.reset()
            self._show_results(self.get_home_results(), callback)
            return

        self._mode = None
        self.query = Query(None, query_str)

        # keyword match
        keyword, argument = query_str.split(" ", 1) if " " in query_str else (query_str, None)

        for mode, keywords in self._keyword_cache.items():
            if keyword in keywords and argument is not None:
                self._mode = mode
                self.query = Query(keyword, argument)
                break

        # non-keyword match
        if not self._mode:
            for mode in get_modes():
                if mode.matches_query_str(query_str):
                    self._mode = mode
                    self.query = Query(None, query_str)
                    break

        self.handle_change(callback)

    def search_triggers(self, min_score: int = 50, limit: int = 50) -> list[Result]:
        self.load_triggers()
        # Cast apps to AppResult objects. Default apps to Gio.DesktopAppInfo.get_all()
        query_str = self.query.argument
        if not query_str:
            return []

        flattened_ = itertools.chain.from_iterable(self._trigger_cache.values())
        sorted_ = sorted(flattened_, key=lambda i: i.search_score(query_str), reverse=True)[:limit]
        return list(filter(lambda searchable: searchable.search_score(query_str) > min_score, sorted_))

    def get_home_results(self) -> Iterable[Result]:
        if limit := Settings.load().max_recent_apps:
            app_mode = get_app_mode()
            for app_result in app_mode.get_home_results(limit):
                self._mode_map[app_result] = app_mode
                yield app_result

    def _show_results(self, results: Iterable[Result], callback: ResultsCallback) -> None:
        """Render a result list immediately, replacing the current one. For the non-streaming
        paths (home, search, fallback, placeholder, action menu); does not touch stream state."""
        self._clear_placeholder_timer()
        callback(results_update(list(results), self.query, self.last_query_result_pick))

    def _show_placeholder(self, callback: ResultsCallback) -> None:
        placeholder = Result(name="Loading...", icon=self._mode.get_placeholder_icon() if self._mode else None)
        self._show_results([placeholder], callback)

    def _clear_placeholder_timer(self) -> None:
        if self._placeholder_timer:
            self._placeholder_timer.cancel()
            self._placeholder_timer = None

    def _emit_results(self, results: list[Result], append: bool, callback: ResultsCallback) -> None:
        self._clear_placeholder_timer()
        callback(results_update(results, self.query, self.last_query_result_pick, append))

    def handle_change(self, callback: ResultsCallback) -> None:
        self._result_buffer.reset()
        self._clear_placeholder_timer()

        if self._mode:
            try:
                self._placeholder_timer = scheduling.timer(PLACEHOLDER_DELAY, lambda: self._show_placeholder(callback))
                self._mode.handle_query(self.query, self._mode_callback(self._mode, callback))
            except Exception:
                # Mode handlers can raise any exception - catch broadly to prevent crashes
                logger.exception("Mode '%s' triggered an error while handling query '%s'", self._mode, self.query)
                self._clear_placeholder_timer()
            return

        # No mode selected, which means search
        results = self.search_triggers()
        # If the search result is empty, add the default items for all other modes (only shortcuts currently)
        if not results and str(self.query):
            for mode in get_modes():
                for fallback_result in mode.get_fallback_results(str(self.query)):
                    results.append(fallback_result)
                    self._mode_map[fallback_result] = mode

        result_objects = [res if isinstance(res, Result) else Result(**res) for res in results]
        self._show_results(result_objects, callback)

    def handle_backspace(self, query_str: str) -> bool:
        if self._mode:
            new_query = self._mode.handle_backspace(query_str)
            if new_query is not None:
                self.query = new_query
                _events.emit("app:set_query", str(new_query))
                return True
        return False

    def activate_result(self, result: Result, callback: ResultsCallback, alt: bool = False) -> None:
        # drop any pending buffer so it doesn't interfere with the activation results
        self._result_buffer.reset()
        action_id: str | None = None

        if not alt:
            self._remember_pick(result)

        if isinstance(result, KeywordTrigger):
            # activate keyword trigger
            effect_utils.handle(effects.set_query(f"{result.keyword} "))
            return

        if alt and result.actions and not isinstance(result, ActionResult):
            # alt activation: show action result list (unless it's already showing)
            self._show_results(self._get_action_results(result), callback)
            return

        if isinstance(result, ActionResult) and result.parent_result and result.action_id:
            # activated an action from the action list: use parent with given action id
            action_id = result.action_id
            result = result.parent_result
        elif result.actions:
            # activated result without alt: use first action
            action_id = next(iter(result.actions))
        else:
            # ignore results with no actions (can be used as headers, spacers or status messages)
            pass

        if action_id is not None:
            mode = self._mode_map.get(result, self._mode)
            if not mode:
                logger.warning("Cannot activate result '%s' because no mode is set", result)
                return
            valid_mode = mode if self._mode == mode else None
            mode.activate_result(action_id, result, self.query, self._mode_callback(valid_mode, callback))
            return

    def _mode_callback(
        self, valid_mode: Mode | None, callback: ResultsCallback
    ) -> Callable[[effects.EffectMessage], None]:
        """
        Callback to handle results and effects from modes.

        valid_mode: Ensures this mode is still active when the callback is called (set to None to skip validation).
        callback: The callback function to handle results and effects.
        """

        def _callback(effect_msg: effects.EffectMessage) -> None:
            if valid_mode not in (self._mode, None):
                return

            self._clear_placeholder_timer()
            if effect_msg["type"] == effects.EffectType.RENDER_RESULTS:
                self._result_buffer.enqueue(
                    effect_msg, lambda results, append: self._emit_results(results, append, callback)
                )
            elif effect_msg["type"] == effects.EffectType.LEGACY_RUN_MANY:
                # effect_utils.handle has no callback to render with, so route any nested render
                # here and let it process the rest (preserving its close behavior).
                for nested_effect in effect_msg["effects"]:
                    if nested_effect["type"] == effects.EffectType.RENDER_RESULTS:
                        self._show_results(nested_effect["results"], callback)
                    else:
                        effect_utils.handle(nested_effect, prevent_close=True)
                if effect_utils.should_close(effect_msg):
                    _events.emit("app:close_launcher")
            else:
                effect_utils.handle(effect_msg)

        return _callback

    def _remember_pick(self, result: Result) -> None:
        """Record the activated result in query history so it preselects for the same query later."""
        query_str = str(self.query)
        if query_str and result.searchable:
            history = QueryHistory.load()
            history[query_str] = result.name
            history.save()

    def _get_action_results(self, result: Result) -> Iterable[Result]:
        for action_id, action_data in result.actions.items():
            yield ActionResult(
                name=action_data["name"],
                icon=action_data.get("icon", result.icon or ""),
                action_id=action_id,
                compact=True,
                parent_result=result,
            )
