# SPDX-FileCopyrightText: 2022 Chris Heywood
#
# SPDX-License-Identifier: GPL-3.0-or-later

import gi

gi.require_version("Secret", "1")
from gi.repository import Gdk, GObject, GLib, Gtk, Secret

import datetime
from email.utils import parsedate_to_datetime
from enum import IntEnum, auto
from json.decoder import JSONDecodeError
import logging
import os
import requests
from threading import Thread
from typing import NamedTuple, Optional
from ssl import SSLCertVerificationError

import iotas.const as const

from iotas.attachment import Attachment
from iotas.nextcloud_sync_worker_configuration import NextcloudSyncWorkerConfiguration
from iotas.note import Note
from iotas.sync_result import SyncResult, ContentPushSyncResult


class LoginRequestResult(IntEnum):

    SUCCESS = auto()
    FAILURE_GENERIC = auto()
    FAILURE_SELF_SIGNED_SSL = auto()
    FAILURE_CERTIFICATE = auto()


class AttachmentDownloadingResult(NamedTuple):

    data: Optional[bytes]
    status_code: int


class NextcloudSyncWorker(GObject.Object):
    __gsignals__ = {
        "ready": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
        "api-check-finished": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "settings-fetched": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
        "secret-service-failure": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "missing-password": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "notes-capability-missing": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    ENDPOINT_V_1_0 = "/index.php/apps/notes/api/v1/"
    ENDPOINT_V_1_4 = "/index.php/apps/notes/api/v1.4/"
    EXPECTED_API_VERSION = "1.4"

    LOGIN_ENDPOINT = "/index.php/login/v2"
    CAPABILITIES_ENDPOINT = "/ocs/v2.php/cloud/capabilities"

    SECRET_ATTRIBUTES = {"service": "nextcloud"}
    SECRET_SCHEMA = Secret.Schema.new(
        "org.gnome.World.Iotas",
        Secret.SchemaFlags.NONE,
        {"service": Secret.SchemaAttributeType.STRING},
    )

    USER_AGENT = f"Iotas/{const.VERSION}"

    CA_CHAIN_FILE = os.path.join(GLib.get_user_data_dir(), "iotas", "server_ssl_ca_chain.pem")

    # From openssl/x509_vfy.h
    OPENSSL_SELF_SIGNED_CERTIFICATE_ERROR_CODES = (18, 19)

    def __init__(self, config_manager: NextcloudSyncWorkerConfiguration) -> None:
        super().__init__()
        self.__config_manager = config_manager
        self.__app_password: Optional[str] = None
        self.__secret_service_failed = False
        self.authenticated = False
        self.__timeout = 10
        self.__api_version = ""

        self.__server = self.__config_manager.nextcloud_endpoint
        self.__username = self.__config_manager.nextcloud_username
        self.__prune_threshold = self.__config_manager.nextcloud_prune_threshold

        if os.path.exists(self.CA_CHAIN_FILE):
            logging.info("Using SSL CA chain file at {}".format(self.CA_CHAIN_FILE))
            self.verify: str | bool = self.CA_CHAIN_FILE
        else:
            self.verify = True

        self.__headers = {
            "User-Agent": self.USER_AGENT,
            "Cache-Control": "no-cache",
        }

    def init_auth(self) -> None:
        """Initialise Nextcloud authentication details from the Secret Service."""
        thread = Thread(target=self.__init_secrets)
        thread.daemon = True
        thread.start()

    def start_login(
        self, server_uri: str, window: Gtk.Window
    ) -> tuple[LoginRequestResult, Optional[str], Optional[str]]:
        """Initiate a new login.

        :param str server_uri: Server instance URI
        :param Gtk.Window window: Main window
        :return: tuple with LoginRequestResult before endpoint URI and auth token (or Nones on
            failures)
        :rtype: tuple[LoginRequestResult, Optional[str], Optional[str]]
        """
        uri = "{}{}".format(server_uri, self.LOGIN_ENDPOINT)
        try:
            res = requests.post(
                uri,
                headers=self.__headers,
                verify=self.verify,
                timeout=self.__timeout,
            )
        except requests.exceptions.SSLError as e:
            if self.exception_is_for_self_signed_certificate(e):
                logging.warning("Login found self-signed certificate: {} for {}".format(e, uri))
                return (LoginRequestResult.FAILURE_SELF_SIGNED_SSL, None, None)
            elif self.get_ssl_verification_error(e) is not None:
                logging.warning(
                    "Login POST encountered SSL certificate problem: {} for {}".format(e, uri)
                )
                return (LoginRequestResult.FAILURE_CERTIFICATE, None, None)
            else:
                logging.warning("Login POST request error: {} for {}".format(e, uri))
                return (LoginRequestResult.FAILURE_GENERIC, None, None)
        except Exception as e:
            logging.warning("Login POST request error: {} for {}".format(e, uri))
            return (LoginRequestResult.FAILURE_GENERIC, None, None)

        if res is None:
            logging.warning("Login POST response is null")
            return (LoginRequestResult.FAILURE_GENERIC, None, None)

        if res.status_code != 200:
            logging.warning(
                "Login POST request failed with status code: {}".format(res.status_code)
            )
            return (LoginRequestResult.FAILURE_GENERIC, None, None)

        try:
            response = res.json()
        except requests.exceptions.JSONDecodeError as e:
            logging.warning("Failed to decode login request to JSON: {} for {}".format(e, res.text))
            return (LoginRequestResult.FAILURE_GENERIC, None, None)

        # TODO change this to not need the window passed in
        def open_url_in_browser(window: Gtk.Window, response: dict) -> None:
            Gtk.show_uri(window, response["login"], Gdk.CURRENT_TIME)

        GLib.idle_add(open_url_in_browser, window, response)
        return (LoginRequestResult.SUCCESS, response["poll"]["endpoint"], response["poll"]["token"])

    def check_endpoint_for_auth_token(self, endpoint: str, token: str) -> tuple[bool, bool]:
        """Poll endpoint for authorisation success.

        :param str endpoint: Endpoint URI
        :param str token: Request token
        :return: Whether authenticated and whether auth. storage succeeded
        :rtype: tuple[bool, bool]
        """
        data = {"token": token}
        try:
            res = requests.post(
                endpoint,
                headers=self.__headers,
                data=data,
                verify=self.verify,
                timeout=self.__timeout,
            )
        except Exception:
            return (False, False)
        if res.status_code != 200:
            return (False, False)

        response = res.json()

        stored = self.__store_password(response["loginName"], response["appPassword"])

        if stored:
            self.__authenticated_and_password_stored(
                response["server"], response["loginName"], response["appPassword"]
            )
        else:
            self.__handle_secret_service_failure(emit=False)

        return (True, stored)

    def sign_out(self) -> None:
        """Clear credentials." """
        logging.debug("Nextcloud signout")
        self.__clear_password()
        self.reset_prune_threshold()

    def check_capabilities(self) -> None:
        """Check server API version."""
        assert self.__username is not None
        assert self.__app_password is not None
        url = self.__server + "/" + self.CAPABILITIES_ENDPOINT
        headers = self.__headers.copy()
        headers["OCS-APIRequest"] = "true"
        headers["Accept"] = "application/json"
        try:
            res = requests.get(
                url,
                headers=headers,
                auth=(self.__username, self.__app_password),
                verify=self.verify,
                timeout=self.__timeout,
            )
        except Exception as e:
            logging.warning(f"GET request for capabilities failed: {e}")
            return

        response = self.__parse_get_response_to_json(res, "capabilities")
        if response is None:
            return
        try:
            capabilities = response["ocs"]["data"]["capabilities"]["notes"]
            api_version = capabilities["api_version"]
            app_version = capabilities["version"]
        except KeyError:
            logging.warning(
                "Couldn't access version elements in server capabilities response. "
                "Is the Nextcloud Notes app installed within Nextcloud?"
            )
            GLib.idle_add(self.emit, "notes-capability-missing")
            return

        logging.info(
            f"Running against Nextcloud Notes server app v{app_version} "
            f"with API versions: {', '.join(api_version)}"
        )
        if self.EXPECTED_API_VERSION not in api_version:
            logging.warning(
                f"Expected API version {self.EXPECTED_API_VERSION} not found in server API "
                f"versions ({', '.join(api_version)})"
            )
        self.__api_version = max(api_version)

        GLib.idle_add(self.emit, "api-check-finished")

    def fetch_settings(self) -> None:
        """Fetch settings from server."""
        res = self.__request_get("settings", {}, self.__timeout)
        if res is None:
            logging.warning("Couldn't fetch settings from server")
            return

        response = self.__parse_get_response_to_json(res, "settings")
        if response is None:
            return
        try:
            file_suffix = response["fileSuffix"]
        except KeyError:
            logging.warning("Couldn't access file suffix setting in server response")
            return

        file_suffix = file_suffix.strip()
        file_suffix = file_suffix.strip(".")
        GLib.idle_add(self.emit, "settings-fetched", file_suffix)

    def get_notes(self) -> SyncResult:
        """Fetch notes from server.

        :return: Sync result object
        :rtype: SyncResult
        """
        params = {}
        if self.__prune_threshold > 0:
            params["pruneBefore"] = self.__prune_threshold

        # Bump network timeout for initial imports, long breaks between syncs and timestamp resets
        two_weeks_ago = (datetime.datetime.now() - datetime.timedelta(weeks=2)).timestamp()
        if self.__prune_threshold > two_weeks_ago:
            timeout = self.__timeout
        else:
            # Two minutes, based on default 10 second timeout
            timeout = self.__timeout * 12
            reason = "Over two weeks since sync" if self.__prune_threshold else "Fetching all notes"
            logging.info(
                f"{reason}, using longer network timeout for potential longer sync ({timeout:.2f}s)"
            )

        res = self.__request_get("notes", params, timeout)
        ret = SyncResult.from_requests_response(res)
        if not ret.success:
            return ret
        if res and "Last-Modified" in res.headers:
            dt = parsedate_to_datetime(res.headers["Last-Modified"])
            self.__prune_threshold = int(dt.timestamp())

        return ret

    def update_note(self, note: Note) -> ContentPushSyncResult:
        """Update a note.

        :param Note note: Note to update
        :return: Result of operation
        :rtype: ContentPushSyncResult
        """
        headers = {"If-Match": '"' + note.etag + '"'}
        headers.update(self.__headers)
        path = f"notes/{note.remote_id}"
        sent_content = note.content
        data = {
            "title": note.title,
            "content": sent_content,
            "category": note.category,
            "favorite": 1 if note.favourite else 0,
        }
        res = self.__request_put(path, headers, data)
        return ContentPushSyncResult.from_requests_response(res, sent_content)

    def create_note(self, note: Note) -> ContentPushSyncResult:
        """Create a note.

        :param Note note: Note to create
        :return: Result of operation
        :rtype: ContentPushSyncResult
        """
        path = "notes"
        sent_content = note.content
        data = {"title": note.title, "content": sent_content, "category": note.category}
        res = self.__request_post(path, data)
        return ContentPushSyncResult.from_requests_response(res, sent_content)

    def delete_note(self, note: Note) -> SyncResult:
        """Delete a note.

        :param Note note: Note to delete
        :return: Result of operation
        :rtype: SyncResult
        """
        path = "notes/{}".format(note.remote_id)
        res = self.__request_delete(path)
        return SyncResult.from_requests_response(res)

    def get_attachment(self, attachment: Attachment) -> AttachmentDownloadingResult:
        """Fetch an attachment from the server.

        :param Attachment attachment: Attachment
        :return: Result object
        :rtype: AttachmentDownloadingResult
        """
        params = {}
        params["path"] = attachment.path

        res = self.__request_get(f"attachment/{attachment.note_remote_id}", params, self.__timeout)

        if res is None:
            return AttachmentDownloadingResult(None, -1)
        elif res.status_code != 200:
            return AttachmentDownloadingResult(None, res.status_code)
        else:
            return AttachmentDownloadingResult(res.content, res.status_code)

    def reset_prune_threshold(self) -> None:
        """Reset the sync threshold."""
        self.__prune_threshold = 0
        self.__config_manager.nextcloud_prune_threshold = self.__prune_threshold

    @GObject.Property(type=str)
    def server(self) -> str:
        return self.__server

    @server.setter
    def set_server(self, value: str) -> None:
        value = value.strip()
        value = GLib.uri_escape_string(value, None, True)
        value = value.strip("/")
        self.__server = value

    @GObject.Property(type=bool, default=False)
    def authenticated(self) -> bool:
        return self.__authenticated

    @authenticated.setter
    def set_authenticated(self, value: bool) -> None:
        self.__authenticated = value

    @GObject.Property(type=int)
    def prune_threshold(self) -> int:
        return self.__prune_threshold

    @GObject.Property(type=bool, default=False)
    def secret_service_failed(self) -> bool:
        return self.__secret_service_failed

    @GObject.Property(type=int)
    def timeout(self) -> int:
        return self.__timeout

    @timeout.setter
    def set_timeout(self, value: int) -> None:
        self.__timeout = value

    @GObject.Property(type=str)
    def api_version(self) -> str:
        return self.__api_version

    @api_version.setter
    def set_api_version(self, value: str) -> None:
        self.__api_version = value

    def __init_secrets(self) -> None:
        # Doing this sync fetch works around some type of libsecret flatpak issue (... so we may
        # as well use the result)
        self.__app_password = self.__fetch_password()
        if self.__app_password is None:
            self.emit("missing-password")
        else:
            self.__check_setup()
        logging.debug("Nextcloud sync setup: {}".format(self.__authenticated))

    def __check_setup(self) -> None:
        if (
            self.__server.strip() != ""
            and self.__username.strip() != ""
            and self.__app_password is not None
        ):
            self.authenticated = True
            self.emit("ready", False)

    def __authenticated_and_password_stored(
        self, endpoint: str, username: str, app_password: str
    ) -> None:
        self.__server = endpoint
        self.__username = username
        self.__app_password = app_password
        self.__config_manager.nextcloud_endpoint = self.__server
        self.__config_manager.nextcloud_username = self.__username

        self.reset_prune_threshold()
        self.authenticated = True
        GLib.idle_add(self.emit, "ready", True)
        logging.debug("Nextcloud login successful")

    def __request_get(self, path: str, params: dict, timeout: float) -> Optional[requests.Response]:
        assert self.__username is not None
        assert self.__app_password is not None
        url = self.__get_url(path)
        try:
            return requests.get(
                url,
                headers=self.__headers,
                auth=(self.__username, self.__app_password),
                params=params,
                verify=self.verify,
                timeout=timeout,
            )
        except Exception as e:
            logging.warning(f"GET request failed: {e}")
            return None

    def __request_put(self, path: str, headers: dict, data: dict) -> Optional[requests.Response]:
        assert self.__username is not None
        assert self.__app_password is not None
        url = self.__get_url(path)
        try:
            return requests.put(
                url,
                headers=headers,
                auth=(self.__username, self.__app_password),
                data=data,
                verify=self.verify,
                timeout=self.__timeout,
            )
        except Exception as e:
            logging.warning(f"PUT request failed: {e}")
            return None

    def __request_post(
        self, path: str, data: dict, files: Optional[dict] = None
    ) -> Optional[requests.Response]:
        assert self.__username is not None
        assert self.__app_password is not None
        url = self.__get_url(path)
        try:
            return requests.post(
                url,
                headers=self.__headers,
                auth=(self.__username, self.__app_password),
                data=data,
                files=files,
                verify=self.verify,
                timeout=self.__timeout,
            )
        except Exception as e:
            logging.warning(f"POST request failed: {e}")
            return None

    def __request_delete(self, path: str) -> Optional[requests.Response]:
        assert self.__username is not None
        assert self.__app_password is not None
        url = self.__get_url(path)
        try:
            return requests.delete(
                url,
                headers=self.__headers,
                auth=(self.__username, self.__app_password),
                verify=self.verify,
                timeout=self.__timeout,
            )
        except Exception as e:
            logging.warning(f"DELETE request failed: {e}")
            return None

    def __get_url(self, path: str) -> str:
        endpoint = self.ENDPOINT_V_1_4 if "attachment" in path else self.ENDPOINT_V_1_0
        return self.__server + "/" + endpoint + path

    def __store_password(self, login: str, password: str) -> bool:
        success = False
        try:
            item_id = "org.gnome.World.Iotas: {}".format(login)
            success = Secret.password_store_sync(
                self.SECRET_SCHEMA,
                self.SECRET_ATTRIBUTES,
                Secret.COLLECTION_DEFAULT,
                item_id,
                password,
                None,
            )
        except Exception as e:
            logging.error(f"Failed to store password: {e}")
        return success

    def __fetch_password(self) -> Optional[str]:
        pwd = None
        try:
            pwd = Secret.password_lookup_sync(self.SECRET_SCHEMA, self.SECRET_ATTRIBUTES, None)
        except GLib.Error as e:
            if "ServiceUnknown" in e.message:
                self.__handle_secret_service_failure(emit=True)
            else:
                logging.error(f"Failed to fetch password: {e}")
        return pwd

    def __handle_secret_service_failure(self, emit: bool):
        self.__secret_service_failed = True
        msg = (
            "Could not connect with the Secret Service. Without the service Nextcloud "
            "authentication details cannot be stored. Look into GNOME Keyring. Restart "
            "the app to try again."
        )
        logging.warning(msg)
        if emit:
            self.emit("secret-service-failure")

    def __clear_password(self) -> None:
        logging.debug("Clearing Nextcloud credentials")
        self.authenticated = False
        self.__app_password = None
        try:
            Secret.password_clear_sync(self.SECRET_SCHEMA, self.SECRET_ATTRIBUTES, None)
        except Exception as e:
            logging.warning(f"Failed to clear password: {e}")

    def __parse_get_response_to_json(
        self, res: Optional[requests.Response], purpose: str
    ) -> Optional[dict]:
        if res is None:
            logging.warning(f"Response for {purpose} is null")
            return None

        if res.status_code != 200:
            logging.warning(
                "Request for {} failed with status code: {}".format(purpose, res.status_code)
            )
            return None

        try:
            response = res.json()
        except JSONDecodeError as e:
            logging.warning(
                "Failed to decode {} request to JSON: {} for {}".format(purpose, e, res.text)
            )
            return None

        return response

    @staticmethod
    def exception_is_for_self_signed_certificate(e: requests.exceptions.SSLError) -> bool:
        ssl_verification_error = NextcloudSyncWorker.get_ssl_verification_error(e)
        if ssl_verification_error is None:
            return False

        return (
            ssl_verification_error.verify_code
            in NextcloudSyncWorker.OPENSSL_SELF_SIGNED_CERTIFICATE_ERROR_CODES
        )

    @staticmethod
    def get_ssl_verification_error(
        e: requests.exceptions.SSLError,
    ) -> Optional[SSLCertVerificationError]:
        # Get the requests SSLError
        if type(e.args) is not tuple or len(e.args) != 1:
            return None
        max_retry_error = e.args[0]
        ssl_error = max_retry_error.reason

        # Get the ssl.SSLCertVerificationError
        if type(ssl_error.args) is not tuple or len(ssl_error.args) != 1:
            return None
        if not isinstance(ssl_error.args[0], SSLCertVerificationError):
            return None
        return ssl_error.args[0]
