# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

from __future__ import annotations

import asyncio
from collections.abc import Callable, Coroutine
from http import HTTPStatus
from logging import Logger, getLogger
from time import time
from typing import Any

from jupyter_server.services.contents.manager import (
    AsyncContentsManager,
    ContentsManager,
)
from jupyter_server.utils import ensure_async
from jupyter_server_fileid.manager import BaseFileIdManager
from tornado.web import HTTPError

from .utils import OutOfBandChanges


class FileLoader:
    """
    A class to centralize all the operation on a file.
    """

    def __init__(
        self,
        file_id: str,
        file_id_manager: BaseFileIdManager,
        contents_manager: AsyncContentsManager | ContentsManager,
        log: Logger | None = None,
        poll_interval: float | None = None,
        max_consecutive_logs: int = 3,
        stop_poll_on_errors_after: float | None = None,
    ) -> None:
        self._file_id: str = file_id

        self._lock = asyncio.Lock()
        self._poll_interval = poll_interval
        self._stop_poll_on_errors_after = stop_poll_on_errors_after
        self._file_id_manager = file_id_manager
        self._max_consecutive_logs = max_consecutive_logs
        self._contents_manager = contents_manager

        self._log = log or getLogger(__name__)
        self._subscriptions: dict[str, Callable[[], Coroutine[Any, Any, None]]] = {}
        self._filepath_subscriptions: dict[str, Callable[[], Coroutine[Any, Any, None] | None]] = {}

        self._watcher = asyncio.create_task(self._watch_file()) if self._poll_interval else None
        self.last_modified = None
        self._current_path = self.path

    @property
    def file_id(self) -> str:
        """File ID"""
        return self._file_id

    @property
    def path(self) -> str:
        """
        The file path.
        """
        path = self._file_id_manager.get_path(self.file_id)
        if path is None:
            raise RuntimeError(f"No path found for file ID '{self.file_id}'")
        return path

    @property
    def number_of_subscriptions(self) -> int:
        """
        The number of rooms subscribed to this file.
        """
        return len(self._subscriptions)

    async def clean(self) -> None:
        """
        Clean up the file.

        Stops the watch task.
        """
        if self._watcher is not None:
            if not self._watcher.cancelled():
                self._watcher.cancel()
            try:
                await self._watcher
            except asyncio.CancelledError:
                self._log.info(f"File watcher for '{self.file_id}' was cancelled")

    def observe(
        self,
        id: str,
        callback: Callable[[], Coroutine[Any, Any, None]],
        filepath_callback: Callable[[], Coroutine[Any, Any, None] | None] | None = None,
    ) -> None:
        """
        Subscribe to the file to get notified about out-of-band file changes.

            Parameters:
                    id (str): Room ID
                    callback (Callable): Callback for notifying the room.
        """
        self._subscriptions[id] = callback
        if filepath_callback is not None:
            self._filepath_subscriptions[id] = filepath_callback

    def unobserve(self, id: str) -> None:
        """
        Unsubscribe to the file.

            Parameters:
                id (str): Room ID
        """
        del self._subscriptions[id]
        if id in self._filepath_subscriptions.keys():
            del self._filepath_subscriptions[id]

    async def load_content(self, format: str, file_type: str) -> dict[str, Any]:
        """
        Load the content of the file.

            Parameters:
                format (str): File format.
                file_type (str): Content type.
                content (bool): Whether to load the content or not.

            Returns:
                model (dict): A dictionary with the metadata and content of the file.
        """
        async with self._lock:
            model = await ensure_async(
                self._contents_manager.get(self.path, format=format, type=file_type, content=True)
            )
            if (
                file_type == "file"
                and "content" in model
                and model["content"]
                and "\r\n" in model["content"]
            ):
                model["content"] = model["content"].replace("\r\n", "\n")
                self._log.debug("Normalizing line endings for %s file on content load", self.path)
            self.last_modified = model["last_modified"]
            return model

    async def maybe_save_content(self, model: dict[str, Any]) -> dict[str, Any] | None:
        """
        Save the content of the file.

            Parameters:
                model (dict): A dictionary with format, type, last_modified,
                    and content of the file.

            Raises:
                OutOfBandChanges: if the file was modified at a latter time than the model

        ### Note:
            If there is changes on disk, this method will raise an OutOfBandChanges exception.
        """
        async with self._lock:
            path = self.path
            if model["type"] not in {"directory", "file", "notebook"}:
                # fall back to file if unknown type, the content manager only knows
                # how to handle these types
                model["type"] = "file"

            m = await ensure_async(
                self._contents_manager.get(
                    path, format=model["format"], type=model["type"], content=False
                )
            )
            # Skip saving if file is not writable
            if not m["writable"]:
                return None

            if self.last_modified == m["last_modified"]:
                self._log.info("Saving file: %s", path)
                # saving is shielded so that it cannot be cancelled
                # otherwise it could corrupt the file
                done_saving = asyncio.Event()
                task = asyncio.create_task(self._save_content(model, done_saving, path))
                saved_model = None
                try:
                    saved_model = await asyncio.shield(task)
                except asyncio.CancelledError:
                    pass
                await done_saving.wait()
                return saved_model
            else:
                # file changed on disk, raise an error
                self.last_modified = m["last_modified"]
                raise OutOfBandChanges

    async def _save_content(
        self, model: dict[str, Any], done_saving: asyncio.Event, path: str
    ) -> dict[str, Any]:
        try:
            try:
                return await self._save_content_at_path(model, path)
            except Exception as save_error:
                # The file may have been renamed after the metadata check in
                # maybe_save_content but before the save reached the contents
                # manager. Retry only if the stable file ID now resolves to a
                # different path; errors for deleted files must still surface.
                try:
                    new_path = self.path
                except Exception:
                    # Deleting a file also removes its file ID. Do not replace
                    # the original contents manager error with the path lookup
                    # failure in that case.
                    raise save_error from None
                if new_path == path:
                    raise
                self._log.info("File moved while saving: %s -> %s; retrying", path, new_path)
                return await self._save_content_at_path(model, new_path)
        finally:
            done_saving.set()

    async def _save_content_at_path(self, model: dict[str, Any], path: str) -> dict[str, Any]:
        m = await ensure_async(self._contents_manager.save(model, path))
        self.last_modified = m["last_modified"]
        # TODO, get rid of the extra `get` here once upstream issue:
        # https://github.com/jupyter-server/jupyter_server/issues/1453 is resolved
        model_with_hash = await ensure_async(
            self._contents_manager.get(
                path,
                content=False,
                require_hash=True,
            )
        )
        return {**m, "hash": model_with_hash["hash"]}

    async def _watch_file(self) -> None:
        """
        Async task for watching a file.
        """
        self._log.info("Watching file: %s", self.path)

        if self._poll_interval is None:
            return

        consecutive_error_logs = 0
        suppression_logged = False
        consecutive_errors_started = None

        while True:
            try:
                await asyncio.sleep(self._poll_interval)
                try:
                    await self.maybe_notify()
                    consecutive_error_logs = 0
                    suppression_logged = False
                    consecutive_errors_started = None
                except Exception as e:
                    # We do not want to terminate the watcher if the content manager request
                    # fails due to timeout, server error or similar temporary issue; we only
                    # terminate if the file is not found or we get unauthorized error for
                    # an extended period of time.
                    if isinstance(e, HTTPError) and e.status_code in {
                        HTTPStatus.NOT_FOUND,
                        HTTPStatus.UNAUTHORIZED,
                    }:
                        if (
                            consecutive_errors_started
                            and self._stop_poll_on_errors_after is not None
                        ):
                            errors_duration = time() - consecutive_errors_started
                            if errors_duration > self._stop_poll_on_errors_after:
                                self._log.warning(
                                    (
                                        "Stopping watching file due to consecutive "
                                        "errors over %s seconds: %s"
                                    ),
                                    self._stop_poll_on_errors_after,
                                    self.path,
                                )
                                break
                        else:
                            consecutive_errors_started = time()
                    # Otherwise we just log the error
                    if consecutive_error_logs < self._max_consecutive_logs:
                        self._log.error("Error watching file %s: %s", self.path, e, exc_info=e)
                        consecutive_error_logs += 1
                    elif not suppression_logged:
                        self._log.warning(
                            "Too many errors while watching %s - suppressing further logs.",
                            self.path,
                        )
                        suppression_logged = True

            except asyncio.CancelledError:
                break

    async def maybe_notify(self) -> None:
        """
        Notifies subscribed rooms about out-of-band file changes.
        """
        do_notify = False
        filepath_change = False
        async with self._lock:
            path = self.path
            if self._current_path != path:
                self._current_path = path
                filepath_change = True

            # Get model metadata; format and type are not need
            model = await ensure_async(self._contents_manager.get(path, content=False))

            if self.last_modified is not None and self.last_modified < model["last_modified"]:
                do_notify = True

            self.last_modified = model["last_modified"]

        if filepath_change:
            # Notify filepath change
            for callback in self._filepath_subscriptions.values():
                await ensure_async(callback())

        if do_notify:
            # Notify out-of-band change
            # callbacks will load the file content, thus release the lock before calling them
            for callback in self._subscriptions.values():
                await callback()


class FileLoaderMapping:
    """Map rooms to file loaders."""

    def __init__(
        self,
        settings: dict,
        log: Logger | None = None,
        file_poll_interval: float | None = None,
        file_stop_poll_on_errors_after: float | None = None,
    ) -> None:
        """
        Args:
            settings: Server settings
            log: [optional] Server log; default to local logger
            file_poll_interval: [optional] Interval between room
                notification; default the loader won't poll
        """
        self._settings = settings
        self.__dict: dict[str, FileLoader] = {}
        self.log = log or getLogger(__name__)
        self.file_poll_interval = file_poll_interval
        self._stop_poll_on_errors_after = file_stop_poll_on_errors_after

    @property
    def contents_manager(self) -> AsyncContentsManager | ContentsManager:
        return self._settings["contents_manager"]

    @property
    def file_id_manager(self) -> BaseFileIdManager:
        return self._settings["file_id_manager"]

    def __contains__(self, file_id: str) -> bool:
        """Test if a file has a loader."""
        return file_id in self.__dict

    def __getitem__(self, file_id: str) -> FileLoader:
        """Get the loader for a given file.

        If there is none, create one.
        """
        path = self.file_id_manager.get_path(file_id)

        # Instantiate the FileLoader if it doesn't exist yet
        file = self.__dict.get(file_id)
        if file is None:
            self.log.info("Creating FileLoader for: %s", path)
            file = FileLoader(
                file_id,
                self.file_id_manager,
                self.contents_manager,
                self.log,
                self.file_poll_interval,
                stop_poll_on_errors_after=self._stop_poll_on_errors_after,
            )
            self.__dict[file_id] = file

        return file

    async def __delitem__(self, file_id: str) -> None:
        """Delete a loader for a given file."""
        await self.remove(file_id)

    async def clear(self) -> None:
        """Clear all loaders."""
        tasks = []
        for id in list(self.__dict):
            loader = self.__dict.pop(id)
            tasks.append(loader.clean())

        await asyncio.gather(*tasks)

    async def remove(self, file_id: str) -> None:
        """Remove the loader for a given file."""
        loader = self.__dict.pop(file_id)
        await loader.clean()
