# -*- coding: utf-8 -*-

# Copyright (c) 2009 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a QNetworkCookieJar subclass with various accept policies.
"""

import os

from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSettings
from PyQt6.QtNetwork import QNetworkCookieJar, QNetworkCookie

from WebBrowser.WebBrowserWindow import WebBrowserWindow

from Utilities.AutoSaver import AutoSaver
import Utilities
import Preferences


class CookieJar(QNetworkCookieJar):
    """
    Class implementing a QNetworkCookieJar subclass with various accept
    policies.
    
    @signal cookiesChanged() emitted after the cookies have been changed
    """
    cookiesChanged = pyqtSignal()
    
    AcceptAlways = 0
    AcceptNever = 1
    AcceptOnlyFromSitesNavigatedTo = 2
    AcceptMax = 2

    KeepUntilExpire = 0
    KeepUntilExit = 1
    KeepMax = 1
    
    Allow = 0
    Block = 1
    AllowForSession = 2
    
    def __init__(self, parent=None):
        """
        Constructor
        
        @param parent reference to the parent object (QObject)
        """
        super().__init__(parent)
        
        self.__loaded = False
        self.__acceptCookies = self.AcceptOnlyFromSitesNavigatedTo
        self.__saveTimer = AutoSaver(self, self.__save)
        
        self.__cookiesFile = os.path.join(Utilities.getConfigDir(),
                                          "web_browser", "cookies.ini")
        
        self.__store = WebBrowserWindow.webProfile().cookieStore()
        self.__store.setCookieFilter(self.__cookieFilter)
        self.__store.cookieAdded.connect(self.__cookieAdded)
        self.__store.cookieRemoved.connect(self.__cookieRemoved)
        
        self.__load()
        self.__store.loadAllCookies()
    
    def close(self):
        """
        Public slot to close the cookie jar.
        """
        if not self.__loaded:
            self.__load()
        
        if self.__keepCookies == self.KeepUntilExit:
            self.clear()
        self.__saveTimer.saveIfNeccessary()
    
    def clear(self):
        """
        Public method to clear all cookies.
        """
        if not self.__loaded:
            self.__load()
        
        self.setAllCookies([])
        self.__store.deleteAllCookies()
        self.cookiesChanged.emit()
    
    def removeCookies(self, cookies):
        """
        Public method to remove a list of cookies.
        
        @param cookies list of cookies to be removed
        @type list of QNetworkCookie
        """
        wasBlocked = self.blockSignals(True)
        for cookie in cookies:
            self.__store.deleteCookie(cookie)
        self.blockSignals(wasBlocked)
        
        self.cookiesChanged.emit()
    
    def removeCookie(self, cookie):
        """
        Public method to remove a cookie.
        
        @param cookie cookie to be removed
        @type QNetworkCookie
        """
        self.__store.deleteCookie(cookie)
        self.cookiesChanged.emit()
    
    def __load(self):
        """
        Private method to load the cookies settings.
        """
        if self.__loaded:
            return
        
        cookieSettings = QSettings(self.__cookiesFile,
                                   QSettings.Format.IniFormat)
        
        # load exceptions
        self.__exceptionsBlock = Preferences.toList(
            cookieSettings.value("Exceptions/block"))
        self.__exceptionsAllow = Preferences.toList(
            cookieSettings.value("Exceptions/allow"))
        self.__exceptionsAllowForSession = Preferences.toList(
            cookieSettings.value("Exceptions/allowForSession"))
        self.__exceptionsBlock.sort()
        self.__exceptionsAllow.sort()
        self.__exceptionsAllowForSession.sort()
        
        self.__acceptCookies = Preferences.getWebBrowser("AcceptCookies")
        self.__keepCookies = Preferences.getWebBrowser("KeepCookiesUntil")
        if self.__keepCookies == self.KeepUntilExit:
            self.clear()
        
        self.__filterTrackingCookies = Preferences.toBool(
            Preferences.getWebBrowser("FilterTrackingCookies"))
        
        self.__loaded = True
        self.cookiesChanged.emit()
    
    def __save(self):
        """
        Private method to save the cookies settings.
        """
        if not self.__loaded:
            return
        
        cookieSettings = QSettings(self.__cookiesFile,
                                   QSettings.Format.IniFormat)
        
        cookieSettings.setValue("Exceptions/block", self.__exceptionsBlock)
        cookieSettings.setValue("Exceptions/allow", self.__exceptionsAllow)
        cookieSettings.setValue("Exceptions/allowForSession",
                                self.__exceptionsAllowForSession)
        
        Preferences.setWebBrowser("AcceptCookies", self.__acceptCookies)
        Preferences.setWebBrowser("KeepCookiesUntil", self.__keepCookies)
        Preferences.setWebBrowser("FilterTrackingCookies",
                                  self.__filterTrackingCookies)
    
    @pyqtSlot(QNetworkCookie)
    def __cookieAdded(self, cookie):
        """
        Private slot handling the addition of a cookie.
        
        @param cookie cookie which was added
        @type QNetworkCookie
        """
        if self.__rejectCookie(cookie, cookie.domain()):
            self.__store.deleteCookie(cookie)
            return
        
        self.insertCookie(cookie)
        self.cookiesChanged.emit()
    
    @pyqtSlot(QNetworkCookie)
    def __cookieRemoved(self, cookie):
        """
        Private slot handling the removal of a cookie.
        
        @param cookie cookie which was removed
        @type QNetworkCookie
        """
        if self.deleteCookie(cookie):
            self.cookiesChanged.emit()
    
    def __cookieFilter(self, request):
        """
        Private method to filter cookies.
        
        Note: This method is used for Qt 5.11+ only.
        
        @param request reference to the cookie filter request object
        @type QWebEngineCookieStore.FilterRequest
        @return flag indicating cookie access is allowed
        @rtype bool
        """
        if not self.__loaded:
            self.__load()
        
        if self.__acceptCookies == self.AcceptNever:
            res = self.__isOnDomainList(self.__exceptionsAllow,
                                        request.origin.host())
            if not res:
                return False
        
        if self.__acceptCookies == self.AcceptAlways:
            res = self.__isOnDomainList(self.__exceptionsBlock,
                                        request.origin.host())
            if res:
                return False
        
        if (
            self.__acceptCookies == self.AcceptOnlyFromSitesNavigatedTo and
            request.thirdParty
        ):
            return False
        
        return True
 
    def __rejectCookie(self, cookie, cookieDomain):
        """
        Private method to test, if a cookie shall be rejected.
        
        @param cookie cookie to be tested
        @type QNetworkCookie
        @param cookieDomain domain of the cookie
        @type str
        @return flag indicating the cookie shall be rejected
        @rtype bool
        """
        if not self.__loaded:
            self.__load()
        
        if self.__acceptCookies == self.AcceptNever:
            res = self.__isOnDomainList(self.__exceptionsAllow, cookieDomain)
            if not res:
                return True
        
        if self.__acceptCookies == self.AcceptAlways:
            res = self.__isOnDomainList(self.__exceptionsBlock, cookieDomain)
            if res:
                return True
        
        if self.__acceptCookies == self.AcceptOnlyFromSitesNavigatedTo:
            mainWindow = WebBrowserWindow.mainWindow()
            if mainWindow is not None:
                browser = mainWindow.getWindow().currentBrowser()
                if browser is not None:
                    url = browser.url()
                    if url.isValid():
                        host = url.host()
                    else:
                        host = ""
                    res = self.__matchDomain(cookieDomain, host)
                    if not res:
                        return True
        
        if self.__filterTrackingCookies and cookie.name().startsWith(b"__utm"):
            return True
        
        return False
    
    def acceptPolicy(self):
        """
        Public method to get the accept policy.
        
        @return current accept policy
        """
        if not self.__loaded:
            self.__load()
        
        return self.__acceptCookies
    
    def setAcceptPolicy(self, policy):
        """
        Public method to set the accept policy.
        
        @param policy accept policy to be set
        """
        if not self.__loaded:
            self.__load()
        
        if policy > self.AcceptMax:
            return
        if policy == self.__acceptCookies:
            return
        
        self.__acceptCookies = policy
        self.__saveTimer.changeOccurred()
    
    def keepPolicy(self):
        """
        Public method to get the keep policy.
        
        @return keep policy
        """
        if not self.__loaded:
            self.__load()
        
        return self.__keepCookies
    
    def setKeepPolicy(self, policy):
        """
        Public method to set the keep policy.
        
        @param policy keep policy to be set
        """
        if not self.__loaded:
            self.__load()
        
        if policy > self.KeepMax:
            return
        if policy == self.__keepCookies:
            return
        
        self.__keepCookies = policy
        self.__saveTimer.changeOccurred()
    
    def blockedCookies(self):
        """
        Public method to return the list of blocked domains.
        
        @return list of blocked domains (list of strings)
        """
        if not self.__loaded:
            self.__load()
        
        return self.__exceptionsBlock
    
    def allowedCookies(self):
        """
        Public method to return the list of allowed domains.
        
        @return list of allowed domains (list of strings)
        """
        if not self.__loaded:
            self.__load()
        
        return self.__exceptionsAllow
    
    def allowForSessionCookies(self):
        """
        Public method to return the list of allowed session cookie domains.
        
        @return list of allowed session cookie domains (list of strings)
        """
        if not self.__loaded:
            self.__load()
        
        return self.__exceptionsAllowForSession
    
    def setBlockedCookies(self, list_):
        """
        Public method to set the list of blocked domains.
        
        @param list_ list of blocked domains (list of strings)
        """
        if not self.__loaded:
            self.__load()
        
        self.__exceptionsBlock = list_[:]
        self.__exceptionsBlock.sort()
        self.__saveTimer.changeOccurred()
    
    def setAllowedCookies(self, list_):
        """
        Public method to set the list of allowed domains.
        
        @param list_ list of allowed domains (list of strings)
        """
        if not self.__loaded:
            self.__load()
        
        self.__exceptionsAllow = list_[:]
        self.__exceptionsAllow.sort()
        self.__saveTimer.changeOccurred()
    
    def setAllowForSessionCookies(self, list_):
        """
        Public method to set the list of allowed session cookie domains.
        
        @param list_ list of allowed session cookie domains (list of strings)
        """
        if not self.__loaded:
            self.__load()
        
        self.__exceptionsAllowForSession = list_[:]
        self.__exceptionsAllowForSession.sort()
        self.__saveTimer.changeOccurred()
    
    def filterTrackingCookies(self):
        """
        Public method to get the filter tracking cookies flag.
        
        @return filter tracking cookies flag (boolean)
        """
        return self.__filterTrackingCookies
    
    def setFilterTrackingCookies(self, filterTrackingCookies):
        """
        Public method to set the filter tracking cookies flag.
        
        @param filterTrackingCookies filter tracking cookies flag (boolean)
        """
        if filterTrackingCookies == self.__filterTrackingCookies:
            return
        
        self.__filterTrackingCookies = filterTrackingCookies
        self.__saveTimer.changeOccurred()
    
    def __isOnDomainList(self, rules, domain):
        """
        Private method to check, if either the rule matches the domain exactly
        or the domain ends with ".rule".
        
        @param rules list of rules (list of strings)
        @param domain domain name to check (string)
        @return flag indicating a match (boolean)
        """
        for rule in rules:
            if rule.startswith("."):
                if domain.endswith(rule):
                    return True
                
                withoutDot = rule[1:]
                if domain == withoutDot:
                    return True
            else:
                domainEnding = domain[-(len(rule) + 1):]
                if (
                    domainEnding and
                    domainEnding[0] == "." and
                    domain.endswith(rule)
                ):
                    return True
                
                if rule == domain:
                    return True
        
        return False
    
    def __matchDomain(self, cookieDomain, siteDomain):
        """
        Private method to check, if a URLs host matches a cookie domain
        according to RFC 6265.
        
        @param cookieDomain domain of the cookie
        @type str
        @param siteDomain domain or host of an URL
        @type str
        @return flag indicating a match
        @rtype bool
        """
        if not siteDomain:
            # empty URLs always match
            return True
        
        if cookieDomain.startswith("."):
            cookieDomain = cookieDomain[1:]
        if siteDomain.startswith("."):
            siteDomain = siteDomain[1:]
        
        if cookieDomain == siteDomain:
            return True
        
        if not siteDomain.endswith(cookieDomain):
            return False
        
        index = siteDomain.find(cookieDomain)
        return index > 0 and siteDomain[index - 1] == "."
    
    def cookies(self):
        """
        Public method to get the cookies of the cookie jar.
        
        @return list of all cookies (list of QNetworkCookie)
        """
        if not self.__loaded:
            self.__load()
        
        return self.allCookies()
    
    def cookieDomains(self):
        """
        Public method to get a list of all domains used by the cookies.
        
        @return list of domain names
        @rtype list of str
        """
        domains = []
        for cookie in self.cookies():
            domain = cookie.domain()
            if domain not in domains:
                domains.append(domain)
        
        return domains
