""""""
from __future__ import annotations

import json
import logging
import time
from datetime import timedelta
from io import BufferedReader
from pathlib import PosixPath
from typing import Any
from uuid import UUID, uuid1

from celery import shared_task
from flask import current_app

from abilian.app import Application
from abilian.core import signals
from abilian.core.models.subjects import User
from abilian.web import url_for

from .views import bp as blueprint

logger = logging.getLogger(__name__)

CHUNK_SIZE = 64 * 1024

DEFAULT_CONFIG = {
    "USER_QUOTA": 100 * 1024**2,  # max 100 Mb for all current files
    "USER_MAX_FILES": 1000,  # max number of files per user
    "DELETE_STALLED_AFTER": 60 * 60 * 24,  # delete files remaining after 1 day
}

CLEANUP_SCHEDULE_ID = f"{__name__}.periodic_clean_upload_directory"
DEFAULT_CLEANUP_SCHEDULE = {"task": CLEANUP_SCHEDULE_ID, "schedule": timedelta(hours=1)}


def is_valid_handle(handle: str) -> bool:
    try:
        UUID(handle)
    except ValueError:
        return False

    return True


class FileUploadsExtension:
    """API for Out-Of-Band file uploads.

    Allow to manage files in forms: file is uploaded to an upload url, a handle is
    returned will be used in the form to refer to this uploaded filed.

    If the form fails to validate the uploaded file is not lost.

    A periodic task cleans the temporary repository.
    """

    def __init__(self, app: Application):
        app.extensions["uploads"] = self
        app.add_template_global(self, "uploads")
        app.register_blueprint(blueprint)
        signals.register_js_api.connect(self._do_register_js_api)

        self.config: dict[str, Any] = {}
        self.config.update(DEFAULT_CONFIG)
        self.config.update(app.config.get("FILE_UPLOADS", {}))
        app.config["FILE_UPLOADS"] = self.config

        # celery schedule
        celerybeat_schedule = app.config.setdefault("CELERYBEAT_SCHEDULE", {})
        if CLEANUP_SCHEDULE_ID not in celerybeat_schedule:
            celerybeat_schedule[CLEANUP_SCHEDULE_ID] = DEFAULT_CLEANUP_SCHEDULE

        path = self.UPLOAD_DIR = app.data_dir / "uploads"
        if not path.exists():
            path.mkdir(mode=0o775, parents=True)

        path.resolve()

    def _do_register_js_api(self, sender: Application):
        app = sender
        js_api = app.js_api.setdefault("upload", {})
        js_api["newFileUrl"] = url_for("uploads.new_file")

    def user_dir(self, user: User) -> PosixPath:
        if user.is_anonymous:
            user_id = "anonymous"
        else:
            user_id = str(user.id)
        return self.UPLOAD_DIR / user_id

    def add_file(self, user: User, file_obj: BufferedReader, **metadata) -> str:
        """Add a new file.

        :returns: file handle
        """
        user_dir = self.user_dir(user)
        if not user_dir.exists():
            user_dir.mkdir(mode=0o775)

        handle = str(uuid1())
        file_path = user_dir / handle

        with file_path.open("wb") as out:
            for chunk in iter(lambda: file_obj.read(CHUNK_SIZE), b""):
                out.write(chunk)

        if metadata:
            meta_file = user_dir / f"{handle}.metadata"
            with meta_file.open("wb") as out:
                metadata_json = json.dumps(metadata, skipkeys=True).encode("ascii")
                out.write(metadata_json)

        return handle

    def get_file(self, user: User, handle: str) -> PosixPath | None:
        """Retrieve a file for a user.

        :returns: a :class:`pathlib.Path` instance to this file,
            or None if no file can be found for this handle.
        """
        user_dir = self.user_dir(user)
        if not user_dir.exists():
            return None

        if not is_valid_handle(handle):
            return None

        file_path = user_dir / handle

        if not file_path.exists() and not file_path.is_file():
            return None

        return file_path

    def get_metadata_file(self, user: User, handle: str) -> PosixPath | None:
        content = self.get_file(user, handle)
        if content is None:
            return None

        metafile = content.parent / f"{handle}.metadata"
        if not metafile.exists():
            return None

        return metafile

    def get_metadata(self, user: User, handle: str) -> dict[str, str]:
        metafile = self.get_metadata_file(user, handle)
        if metafile is None:
            return {}

        try:
            with metafile.open("rb") as in_:
                meta = json.load(in_)
        except Exception:
            meta = {}

        return meta

    def remove_file(self, user, handle):
        paths = (self.get_file(user, handle), self.get_metadata_file(user, handle))

        for file_path in paths:
            if file_path is not None:
                try:
                    file_path.unlink()
                except Exception:
                    logger.exception("Error during remove file")

    def clear_stalled_files(self):
        """Scan upload directory and delete stalled files.

        Stalled files are files uploaded more than
        `DELETE_STALLED_AFTER` seconds ago.
        """
        # FIXME: put lock in directory?
        CLEAR_AFTER = self.config["DELETE_STALLED_AFTER"]
        minimum_age = time.time() - CLEAR_AFTER

        for user_dir in self.UPLOAD_DIR.iterdir():
            if not user_dir.is_dir():
                logger.error("Found non-directory in upload dir: %r", bytes(user_dir))
                continue

            for content in user_dir.iterdir():
                if not content.is_file():
                    logger.error(
                        "Found non-file in user upload dir: %r", bytes(content)
                    )
                    continue

                if content.stat().st_ctime < minimum_age:
                    content.unlink()


# Task scheduled to run every hour: make it expire after 50min.
@shared_task(expires=3000)
def periodic_clean_upload_directory():
    """This task should be run periodically.

    Default config sets up schedule using
    :data:`DEFAULT_CLEANUP_SCHEDULE`. `CELERYBEAT_SCHEDULE` key is
    :data:`CLEANUP_SCHEDULE_ID`.
    """
    with current_app.test_request_context("/tasks/periodic_clean_upload_directory"):
        uploads = current_app.extensions["uploads"]
        uploads.clear_stalled_files()
