from __future__ import annotations

import abc
import asyncio
import inspect
import json
import logging
import time
import traceback
import warnings
import weakref
from datetime import date
from pathlib import Path
from typing import *

import langcodes
import pytz
import starlette.datastructures

import rio
import rio.assets

from .. import (
    assets,
    data_models,
    language_info,
    routing,
    session,
    user_settings_module,
    utils,
)
from ..transports import (
    AbstractTransport,
    TransportClosedIntentionally,
    TransportInterrupted,
)

__all__ = ["AbstractAppServer"]


class AbstractAppServer(abc.ABC):
    def __init__(
        self,
        app: rio.App,
        *,
        running_in_window: bool,
        debug_mode: bool,
    ):
        self.app = app
        self.running_in_window = running_in_window
        self.debug_mode = debug_mode

        self._session_serve_tasks = dict[rio.Session, asyncio.Task[object]]()
        self._disconnected_sessions = dict[rio.Session, float]()

    @property
    def sessions(self) -> list[rio.Session]:
        return list(self._session_serve_tasks)

    async def _on_start(self) -> None:
        # Trigger the app's startup event
        #
        # This will be done blockingly, so the user can prepare any state before
        # connections are accepted. This is also why it's called before the
        # internal event.
        await self._call_on_app_start()

        # If running as a server, periodically clean up expired sessions
        if not self.running_in_window:
            asyncio.create_task(
                _periodically_clean_up_expired_sessions(weakref.ref(self)),
                name="Periodic session cleanup",
            )

    async def _on_close(self) -> None:
        # Close all sessions
        rio._logger.debug(
            f"App server shutting down; closing {len(self.sessions)} active session(s)"
        )

        results = await asyncio.gather(
            *(sess._close(close_remote_session=True) for sess in self.sessions),
            return_exceptions=True,
        )
        for result in results:
            if isinstance(result, BaseException):
                traceback.print_exception(
                    type(result), result, result.__traceback__
                )

        await self._call_on_app_close()

    async def _call_on_app_start(self) -> None:
        rio._logger.debug("Calling `on_app_start`")

        if self.app._on_app_start is None:
            return

        try:
            result = self.app._on_app_start(self.app)

            if inspect.isawaitable(result):
                await result

        # Display and discard exceptions
        except Exception:
            print("Exception in `on_app_start` event handler:")
            traceback.print_exc()

    async def _call_on_app_close(self) -> None:
        rio._logger.debug("Calling `on_app_close`")

        if self.app._on_app_close is None:
            return

        try:
            result = self.app._on_app_close(self.app)

            if inspect.isawaitable(result):
                await result

        # Display and discard exceptions
        except Exception:
            print("Exception in `_on_app_close` event handler:")
            traceback.print_exc()

    @abc.abstractmethod
    async def file_chooser(
        self,
        session: rio.Session,
        *,
        file_extensions: Iterable[str] | None = None,
        multiple: bool = False,
    ) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
        raise NotImplementedError

    @abc.abstractmethod
    def url_for_user_asset(self, relative_asset_path: Path) -> rio.URL:
        """
        Returns the URL of an asset from the app's `assets_dir`. This must be a
        permalink, i.e. a URL that doesn't change if the server is restarted.
        """
        raise NotImplementedError

    @abc.abstractmethod
    def weakly_host_asset(self, asset: assets.HostedAsset) -> str:
        """
        Hosts an asset as long as it is alive. Returns the asset's URL as a
        string.
        """
        raise NotImplementedError

    def host_asset_with_timeout(
        self, asset: assets.HostedAsset, timeout: float
    ) -> str:
        """
        Hosts an asset for a limited time. Returns the asset's URL as a string.
        """
        url = self.weakly_host_asset(asset)

        async def keep_alive():
            await asyncio.sleep(timeout)
            _ = asset

        asyncio.create_task(
            keep_alive(), name=f"Keep asset {asset} alive for {timeout} sec"
        )

        return url

    def _after_session_closed(self, session: rio.Session) -> None:
        """
        Called by `Session.close()`. Gives the server an opportunity to clean
        up.
        """
        # Stop the task that's listening for incoming messages
        task = self._session_serve_tasks.pop(session)
        task.cancel()

    async def create_session(
        self,
        initial_message: data_models.InitialClientMessage,
        *,
        transport: AbstractTransport,
        client_ip: str,
        client_port: int,
        http_headers: starlette.datastructures.Headers,
    ) -> rio.Session:
        """
        Creates a new session.

        ## Raises

        `NavigationFailed`: If a page guard crashes
        """
        # Normalize and deduplicate the languages
        preferred_languages: list[str] = []

        for language in initial_message.preferred_languages:
            try:
                language = langcodes.standardize_tag(language)
            except ValueError:
                continue

            if language not in preferred_languages:
                preferred_languages.append(language)

        if len(preferred_languages) == 0:
            preferred_languages.append("en-US")

        # Get locale information
        if len(initial_message.decimal_separator) != 1:
            logging.warning(
                f'Client sent invalid decimal separator "{initial_message.decimal_separator}". Using "." instead.'
            )
            initial_message.decimal_separator = "."

        if len(initial_message.thousands_separator) > 1:
            logging.warning(
                f'Client sent invalid thousands separator "{initial_message.thousands_separator}". Using "" instead.'
            )
            initial_message.thousands_separator = ""

        # There does not seem to be any good way to determine the first day of
        # the week in JavaScript. Look it up based on the preferred language.
        first_day_of_week = language_info.get_week_start_day(
            preferred_languages[0]
        )

        # Make sure the date format string is valid
        try:
            formatted_date = date(3333, 11, 22).strftime(
                initial_message.date_format_string
            )
        except ValueError:
            date_format_string_is_valid = False
        else:
            date_format_string_is_valid = (
                "33" in formatted_date
                and "11" in formatted_date
                and "22" in formatted_date
            )

        if not date_format_string_is_valid:
            logging.warning(
                f'Client sent invalid date format string "{initial_message.date_format_string}". Using "%Y-%m-%d" instead.'
            )
            initial_message.date_format_string = "%Y-%m-%d"

        # Parse the timezone
        try:
            timezone = pytz.timezone(initial_message.timezone)
        except pytz.UnknownTimeZoneError:
            logging.warning(
                f'Client sent unknown timezone "{initial_message.timezone}". Using UTC instead.'
            )
            timezone = pytz.UTC

        # Set the theme according to the user's preferences
        theme = self.app._theme
        if isinstance(theme, tuple):
            if initial_message.prefers_light_theme:
                theme = theme[0]
            else:
                theme = theme[1]

        # Prepare the initial URL. This will be exposed to the session as the
        # `active_page_url`, but overridden later once the page guards have been
        # run.
        initial_page_url = rio.URL(initial_message.url.lower())
        base_url = (
            initial_page_url.with_path("").with_query("").with_fragment("")
        )

        # Create the session
        sess = session.Session(
            app_server_=self,
            transport=transport,
            client_ip=client_ip,
            client_port=client_port,
            http_headers=http_headers,
            base_url=base_url,
            active_page_url=initial_page_url,
            timezone=timezone,
            preferred_languages=preferred_languages,
            month_names_long=initial_message.month_names_long,
            day_names_long=initial_message.day_names_long,
            date_format_string=initial_message.date_format_string,
            first_day_of_week=first_day_of_week,
            decimal_separator=initial_message.decimal_separator,
            thousands_separator=initial_message.thousands_separator,
            window_width=initial_message.window_width,
            window_height=initial_message.window_height,
            theme_=theme,
        )

        # Deserialize the user settings
        await sess._load_user_settings(initial_message.user_settings)

        # Add any remaining attachments
        for attachment in self.app.default_attachments:
            if not isinstance(attachment, user_settings_module.UserSettings):
                sess.attach(attachment)

        # Start listening for incoming messages. This should happen before
        # `on_session_start` is called, so that we don't deadlock in case
        # someone calls a method that requires a response from the client.
        self._session_serve_tasks[sess] = asyncio.create_task(
            self._serve_session(sess),
            name=f"`Session.serve` for session id `{id(sess)}`",
        )

        # Trigger the `on_session_start` event.
        #
        # Since this event is often used for important initialization tasks like
        # adding attachments, actually wait for it to finish before continuing.
        #
        # However, also don't run it too early, since this function expects a
        # (mostly) functioning session.
        #
        # TODO: Figure out which values are still missing, and expose them,
        #       expose placeholders, or document that they aren't available.
        start_time = time.monotonic()
        await sess._call_event_handler(
            self.app._on_session_start,
            sess,
            refresh=False,
        )
        duration = time.monotonic() - start_time

        if duration > 5:
            warnings.warn(
                f"Session startup was delayed for {duration:.0f} seconds by"
                f" `on_session_start`. If you have long-running operations that"
                f" don't have to finish before the session starts, you should"
                f" execute them in a background task."
            )

        # Run any page guards for the initial page. Throws a `NavigationFailed`
        # if a page guard crashed.
        (
            active_page_instances,
            active_page_url_absolute,
        ) = routing.check_page_guards(sess, initial_page_url)

        # Is this a page, or a full URL to another site?
        try:
            utils.make_url_relative(
                sess._base_url,
                active_page_url_absolute,
            )

        # This is an external URL. Navigate to it
        except ValueError:

            async def history_worker() -> None:
                await sess._evaluate_javascript(
                    f"""
                    window.location.href = {json.dumps(str(active_page_url_absolute))};
                    """
                )

            sess.create_task(history_worker(), name="navigate to external URL")

            # TODO: End the session? Abort initialization?

        # Set the initial page URL. When connecting to the server, all relevant
        # page guards execute. These may change the URL of the page, so the
        # client needs to take care to update the browser's URL to match the
        # server's.
        if active_page_url_absolute != initial_page_url:

            async def update_url_worker():
                js_page_url = json.dumps(str(active_page_url_absolute))
                await sess._evaluate_javascript(
                    f"""
                    console.trace("Updating browser URL to match the one modified by guards:", {js_page_url});
                    window.history.replaceState(null, "", {js_page_url});
                    """
                )

            sess.create_task(
                update_url_worker(),
                name="Update browser URL to match the one modified by guards",
            )

        # Update the session's active page and instances
        sess._active_page_instances = tuple(active_page_instances)
        sess._active_page_url = active_page_url_absolute

        # Apply the CSS for the chosen theme
        await sess._apply_theme(theme)

        # Send the first `updateComponentStates` message
        await sess._refresh()

        return sess

    async def _serve_session(self, sess: rio.Session) -> None:
        try:
            await sess.serve()
        except TransportClosedIntentionally:
            sess.close()
        except TransportInterrupted:
            # Connection was interrupted, mark the session as disconnected but
            # keep it alive for a while to see if the client reconnects
            sess._transport = None


async def _periodically_clean_up_expired_sessions(
    app_server_ref: weakref.ReferenceType[AbstractAppServer],
):
    LOOP_INTERVAL = 60 * 15
    SESSION_LIFETIME = 60 * 60

    while True:
        await asyncio.sleep(LOOP_INTERVAL)

        app_server = app_server_ref()
        if app_server is None:
            return

        now = time.monotonic()
        cutoff = now - SESSION_LIFETIME

        disconnected_sessions = list(app_server._disconnected_sessions.items())
        for sess, disconnect_time in disconnected_sessions:
            if disconnect_time < cutoff:
                sess.close()

        # Drop the reference to the app server
        app_server = None
