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 json.decoder import JSONDecodeError
import logging
import os
import requests
from threading import Thread
from typing import Optional, Tuple
from ssl import SSLCertVerificationError

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


class LoginRequestResult:
    SUCCESS = 0
    FAILURE_GENERIC = 1
    FAILURE_SELF_SIGNED_SSL = 2
    FAILURE_CERTIFICATE = 3


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/"
    EXPECTED_API_VERSION = "1.3"

    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 = None
        self.__secret_service_failed = False
        self.authenticated = False
        self.__timeout = 10

        self.__server = self.__config_manager.get_nextcloud_endpoint()
        self.__username = self.__config_manager.get_nextcloud_username()
        self.__prune_threshold = self.__config_manager.get_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 = 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."""
        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("GET request for capabilities failed: %s", 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)})"
            )

        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 "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 reset_prune_threshold(self) -> None:
        """Reset the sync threshold."""
        self.__prune_threshold = 0
        self.__config_manager.set_nextcloud_prune_threshold(self.__prune_threshold)

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

    @server.setter
    def 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 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 timeout(self, value: int) -> None:
        self.__timeout = 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.set_nextcloud_endpoint(self.__server)
        self.__config_manager.set_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]:
        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("GET request failed: %s", e)
            return None

    def __request_put(self, path: str, headers: dict, data: dict) -> Optional[requests.Response]:
        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("PUT request failed: %s", e)
            return None

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

    def __request_delete(self, path: str) -> Optional[requests.Response]:
        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("DELETE request failed: %s", e)
            return None

    def __get_url(self, path: str) -> str:
        return self.__server + "/" + self.ENDPOINT_V_1_0 + 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("Failed to store password: %s", 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("Failed to fetch password: %s", 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("Failed to clear password: %s", 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]
