#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program.  If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import OrderedDict
from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from django.conf import settings
from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware
from django.urls import get_script_prefix, resolve
from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers
from django.utils.deprecation import MiddlewareMixin
from django.utils.translation.trans_real import (
    check_for_language, get_supported_language_variant, language_code_re,
    parse_accept_lang_header,
)

from pretix.base.i18n import get_language_without_region
from pretix.base.settings import global_settings_object
from pretix.multidomain.urlreverse import (
    get_event_domain, get_organizer_domain,
)
from pretix.presale.style import get_fonts

_supported = None


def get_supported_language(requested_language, allowed_languages, default_language):
    language = requested_language
    if language not in allowed_languages:
        firstpart = language.split('-')[0]
        if firstpart in allowed_languages:
            language = firstpart
        else:
            language = default_language
            for lang in allowed_languages:
                if lang.startswith(firstpart + '-'):
                    language = lang
                    break
    if language not in allowed_languages:
        # This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
        # not part of settings.locales
        language = allowed_languages[0]
    return language


class LocaleMiddleware(MiddlewareMixin):

    """
    This middleware sets the correct locale and timezone
    for a request.
    """

    def process_request(self, request: HttpRequest):
        language = get_language_from_request(request)
        # Normally, this middleware runs *before* the event is set. However, on event frontend pages it
        # might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
        # set and can be taken into account for the decision.
        if not request.path.startswith(get_script_prefix() + 'control'):
            if hasattr(request, 'event'):
                settings_holder = request.event
            elif hasattr(request, 'organizer'):
                settings_holder = request.organizer
            else:
                settings_holder = None

            if settings_holder:
                language = get_supported_language(
                    language,
                    settings_holder.settings.locales,
                    settings_holder.settings.locale,
                )
                if '-' not in language and settings_holder.settings.region:
                    language += '-' + settings_holder.settings.region
        else:
            gs = global_settings_object(request)
            if '-' not in language and gs.settings.region:
                language += '-' + gs.settings.region

        translation.activate(language)
        request.LANGUAGE_CODE = get_language_without_region()

        tzname = None
        if hasattr(request, 'event'):
            tzname = request.event.settings.timezone
        elif hasattr(request, 'organizer') and 'timezone' in request.organizer.settings._cache():
            tzname = request.organizer.settings.timezone
        elif request.user.is_authenticated:
            tzname = request.user.timezone
        if tzname:
            try:
                timezone.activate(ZoneInfo(tzname))
                request.timezone = tzname
            except ZoneInfoNotFoundError:
                pass
        else:
            timezone.deactivate()

    def process_response(self, request: HttpRequest, response: HttpResponse):
        language = translation.get_language()
        patch_vary_headers(response, ('Accept-Language',))
        if 'Content-Language' not in response:
            response['Content-Language'] = language
        return response


def get_language_from_customer_settings(request: HttpRequest) -> str:
    if getattr(request, 'customer', None):
        lang_code = request.customer.locale
        if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
            return lang_code


def get_language_from_user_settings(request: HttpRequest) -> str:
    if request.user.is_authenticated:
        lang_code = request.user.locale
        if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
            return lang_code


def get_language_from_cookie(request: HttpRequest) -> str:
    lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
    try:
        return get_supported_language_variant(lang_code)
    except LookupError:
        pass


def get_language_from_event(request: HttpRequest) -> str:
    if hasattr(request, 'event'):
        lang_code = request.event.settings.locale
        try:
            return get_supported_language_variant(lang_code)
        except LookupError:
            pass


def get_language_from_browser(request: HttpRequest) -> str:
    accept = request.headers.get('Accept-Language', '')
    for accept_lang, unused in parse_accept_lang_header(accept):
        if accept_lang == '*':
            break

        if not language_code_re.search(accept_lang):
            continue

        try:
            return get_supported_language_variant(accept_lang)
        except LookupError:
            continue


def get_default_language():
    try:
        return get_supported_language_variant(settings.LANGUAGE_CODE)
    except LookupError:  # NOQA
        return settings.LANGUAGE_CODE


def get_language_from_request(request: HttpRequest) -> str:
    """
    Analyzes the request to find what language the user wants the system to
    show. Only languages listed in settings.LANGUAGES are taken into account.
    If the user requests a sublanguage where we have a main language, we send
    out the main language.
    """
    global _supported
    if _supported is None:
        _supported = OrderedDict(settings.LANGUAGES)

    if request.path.startswith(get_script_prefix() + 'control'):
        return (
            get_language_from_user_settings(request)
            or get_language_from_customer_settings(request)
            or get_language_from_cookie(request)
            or get_language_from_browser(request)
            or get_language_from_event(request)
            or get_default_language()
        )
    else:
        return (
            get_language_from_cookie(request)
            or get_language_from_customer_settings(request)
            or get_language_from_user_settings(request)
            or get_language_from_browser(request)
            or get_language_from_event(request)
            or get_default_language()
        )


def _parse_csp(header):
    h = {}
    for part in header.split(';'):
        k, v = part.strip().split(' ', 1)
        h[k.strip()] = v.split(' ')
    return h


def _render_csp(h):
    return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)


def _merge_csp(a, b):
    for k, v in a.items():
        if k in b:
            a[k] += [i for i in b[k] if i not in a[k]]

    for k, v in b.items():
        if k not in a:
            a[k] = b[k]

    for k, v in a.items():
        if "'unsafe-inline'" in v:
            # If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
            a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]


class SecurityMiddleware(MiddlewareMixin):
    CSP_EXEMPT = (
        '/api/v1/docs/',
    )

    def process_response(self, request, resp):
        def nested_dict_values(d):
            for v in d.values():
                if isinstance(v, dict):
                    yield from nested_dict_values(v)
                else:
                    if isinstance(v, str):
                        yield v

        url = resolve(request.path_info)

        if settings.DEBUG and resp.status_code >= 400:
            # Don't use CSP on debug error page as it breaks of Django's fancy error
            # pages
            return resp

        resp['X-XSS-Protection'] = '1'

        # We just need to have a P3P, not matter whats in there
        # https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/
        # https://github.com/pretix/pretix/issues/765
        resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'

        img_src = []
        gs = global_settings_object(request)
        if gs.settings.leaflet_tiles:
            img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))

        font_src = set()
        if hasattr(request, 'event'):
            for font in get_fonts(request.event, pdf_support_required=False).values():
                for path in list(nested_dict_values(font)):
                    font_location = urlparse(path)
                    if font_location.scheme and font_location.netloc:
                        font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))

        h = {
            'default-src': ["{static}"],
            'script-src': ['{static}'],
            'object-src': ["'none'"],
            'frame-src': ['{static}'],
            'style-src': ["{static}", "{media}"],
            'connect-src': ["{dynamic}", "{media}"],
            'img-src': ["{static}", "{media}", "data:"] + img_src,
            'font-src': ["{static}"] + list(font_src),
            'media-src': ["{static}", "data:"],
            # form-action is not only used to match on form actions, but also on URLs
            # form-actions redirect to. In the context of e.g. payment providers or
            # single-sign-on this can be nearly anything, so we cannot really restrict
            # this. However, we'll restrict it to HTTPS.
            'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
        }
        # Only include pay.google.com for wallet detection purposes on the Payment selection page
        if (
                url.url_name == "event.order.pay.change" or
                (url.url_name == "event.checkout" and url.kwargs['step'] == "payment")
        ):
            h['script-src'].append('https://pay.google.com')
            h['frame-src'].append('https://pay.google.com')
            h['connect-src'].append('https://google.com/pay')
        if settings.LOG_CSP:
            h['report-uri'] = ["/csp_report/"]
        if 'Content-Security-Policy' in resp:
            _merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
        if settings.CSP_ADDITIONAL_HEADER:
            _merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))

        staticdomain = "'self'"
        dynamicdomain = "'self'"
        mediadomain = "'self'"
        if settings.MEDIA_URL.startswith('http'):
            mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
        if settings.STATIC_URL.startswith('http'):
            staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
        if settings.SITE_URL.startswith('http'):
            if settings.SITE_URL.find('/', 9) > 0:
                staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
                dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
            else:
                staticdomain += " " + settings.SITE_URL
                dynamicdomain += " " + settings.SITE_URL

        if hasattr(request, 'organizer') and request.organizer:
            if hasattr(request, 'event') and request.event:
                domain = get_event_domain(request.event, fallback=True)
            else:
                domain = get_organizer_domain(request.organizer)
            if domain:
                siteurlsplit = urlsplit(settings.SITE_URL)
                if siteurlsplit.port and siteurlsplit.port not in (80, 443):
                    domain = '%s:%d' % (domain, siteurlsplit.port)
                dynamicdomain += " " + domain

        if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
            resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
                                                                    media=mediadomain)
            for k, v in h.items():
                h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
            resp['Content-Security-Policy'] = _render_csp(h)
        elif 'Content-Security-Policy' in resp:
            del resp['Content-Security-Policy']

        return resp


class CustomCommonMiddleware(CommonMiddleware):

    def get_full_path_with_slash(self, request):
        """
        Raise an error regardless of DEBUG mode when in POST, PUT, or PATCH.
        """
        new_path = super().get_full_path_with_slash(request)
        if request.method in ('POST', 'PUT', 'PATCH'):
            raise Http404('Please append a / at the end of the URL')
        return new_path
