# Copyright (C) 2010-2024 RhodeCode GmbH
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License, version 3
# (only), as published by the Free Software Foundation.
#
# 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 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 <http://www.gnu.org/licenses/>.
#
# This program is dual-licensed. If you wish to learn more about the
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/

import re
import os
import logging
import urllib.request
import urllib.parse
import urllib.error
import urllib.parse

import requests
from pyramid.httpexceptions import HTTPNotAcceptable

from rhodecode import ConfigGet
from rhodecode.lib.middleware import simplevcs
from rhodecode.lib.middleware.utils import get_path_info
from rhodecode.lib.utils import is_valid_repo
from rhodecode.lib.str_utils import safe_str

log = logging.getLogger(__name__)


class SimpleSvnApp(object):
    IGNORED_HEADERS = ["connection", "keep-alive", "content-encoding", "transfer-encoding", "content-length"]
    rc_extras = {}

    def __init__(self, config):
        self.config = config
        self.session = requests.Session()

    def __call__(self, environ, start_response):
        request_headers = self._get_request_headers(environ)
        data_io = environ["wsgi.input"]
        req_method: str = environ["REQUEST_METHOD"]
        has_content_length: bool = "CONTENT_LENGTH" in environ

        path_info = self._get_url(self.config.get("subversion_http_server_url", ""), get_path_info(environ))
        transfer_encoding = environ.get("HTTP_TRANSFER_ENCODING", "")
        log.debug("Handling: %s method via `%s` has_content_length:%s", req_method, path_info, has_content_length)

        # stream control flag, based on request and content type...
        stream = False
        if req_method in ["MKCOL"] or has_content_length:
            # NOTE(johbo): Avoid that we end up with sending the request in chunked
            # transfer encoding (mainly on Gunicorn). If we know the content
            # length, then we should transfer the payload in one request.
            data_io = data_io.read()

        if req_method in ["GET", "PUT"] or transfer_encoding == "chunked":
            # NOTE(marcink): when getting/uploading files, we want to STREAM content
            # back to the client/proxy instead of buffering it here...
            stream = True

        stream = stream
        log.debug("Calling SVN PROXY at `%s`, using method:%s. Stream: %s", path_info, req_method, stream)

        call_kwargs = dict(data=data_io, headers=request_headers, stream=stream)
        if req_method in ["HEAD", "DELETE"]:
            # NOTE(marcink): HEAD might be deprecated for SVN 1.14+ protocol
            del call_kwargs["data"]

        try:
            response = self.session.request(req_method, path_info, **call_kwargs)
        except requests.ConnectionError:
            log.exception("ConnectionError occurred for endpoint %s", path_info)
            raise

        if response.status_code not in [200, 401]:
            text = "\n{}".format(safe_str(response.text)) if response.text else ""
            if response.status_code >= 500:
                log.error("Got SVN response:%s with text:`%s`", response, text)
            else:
                log.debug("Got SVN response:%s with text:`%s`", response, text)
        else:
            log.debug("got response code: %s", response.status_code)

        response_headers = self._get_response_headers(response.headers)
        start_response(f"{response.status_code} {response.reason}", response_headers)
        return response.iter_content(chunk_size=1024)

    def _get_url(self, svn_http_server, path):
        svn_http_server_url = (svn_http_server or "").rstrip("/")
        url_path = urllib.parse.urljoin(svn_http_server_url + "/", (path or "").lstrip("/"))
        url_path = urllib.parse.quote(url_path, safe="/:=~+!$,;'")
        return url_path

    def _get_txn_id(self, environ):
        url = environ["RAW_URI"]

        # Define the regex pattern
        pattern = r"/txr/([^/]+)/"

        # Search for the pattern in the URL
        match = re.search(pattern, url)

        # Check if a match is found and extract the captured group
        if match:
            txn_id = match.group(1)
            return txn_id

    def _get_request_headers(self, environ):
        headers = {}
        whitelist = {"Authorization": {}}
        for key in environ:
            if key in whitelist:
                headers[key] = environ[key]
            elif not key.startswith("HTTP_"):
                continue
            else:
                new_key = key.split("_")
                new_key = [k.capitalize() for k in new_key[1:]]
                new_key = "-".join(new_key)
                headers[new_key] = environ[key]

        if "CONTENT_TYPE" in environ:
            headers["Content-Type"] = environ["CONTENT_TYPE"]

        if "CONTENT_LENGTH" in environ:
            headers["Content-Length"] = environ["CONTENT_LENGTH"]

        return headers

    def _get_response_headers(self, headers):
        headers = [(h, headers[h]) for h in headers if h.lower() not in self.IGNORED_HEADERS]

        return headers


class DisabledSimpleSvnApp(object):
    def __init__(self, config):
        self.config = config

    def __call__(self, environ, start_response):
        reason = "Cannot handle SVN call because: SVN HTTP Proxy is not enabled"
        log.warning(reason)
        return HTTPNotAcceptable(reason)(environ, start_response)


class SimpleSvn(simplevcs.SimpleVCS):
    """
    details: https://svn.apache.org/repos/asf/subversion/trunk/notes/http-and-webdav/webdav-protocol

    Read Commands  :  (OPTIONS, PROPFIND, GET, REPORT)

    GET: fetch info about resources
    PROPFIND: Used to retrieve properties of resources.
    REPORT: Used for specialized queries to the repository. E.g History etc...
    OPTIONS: request is sent to an SVN server, the server responds with information about the available HTTP
        methods and other server capabilities.

    Write Commands  :  (MKACTIVITY, PROPPATCH, PUT, CHECKOUT, MKCOL, MOVE,
    --------------      COPY, DELETE, LOCK, UNLOCK, MERGE)

    With the exception of LOCK/UNLOCK, every write command performs some
    sort of DeltaV commit operation.  In DeltaV, a commit always starts
    by creating a transaction (MKACTIVITY), applies a log message
    (PROPPATCH), does some other write methods, and then ends by
    committing the transaction (MERGE).  If the MERGE fails, the client
    may try to remove the transaction with a DELETE.

    PROPPATCH: Used to set and/or remove properties on resources.
    MKCOL: Creates a new collection (directory).
    DELETE: Removes a resource.
    COPY and MOVE: Used for copying and moving resources.
    MERGE: Used to merge changes from different branches.
    CHECKOUT, CHECKIN, UNCHECKOUT: DeltaV methods for managing working resources and versions.
    """

    SCM = "svn"
    READ_ONLY_COMMANDS = ("OPTIONS", "PROPFIND", "GET", "REPORT")
    WRITE_COMMANDS = ("MERGE", "POST", "PUT", "COPY", "MOVE", "DELETE", "MKCOL")
    DEFAULT_HTTP_SERVER = "http://svn:8090"

    def _get_repository_name(self, environ):
        """
        Gets repository name out of PATH_INFO header

        :param environ: environ where PATH_INFO is stored
        """
        path = get_path_info(environ).split("!")
        repo_name = path[0].strip("/")

        # SVN includes the whole path in it's requests, including
        # subdirectories inside the repo. Therefore we have to search for
        # the repo root directory.
        if not is_valid_repo(repo_name, self.base_path, explicit_scm=self.SCM):
            current_path = ""
            for component in repo_name.split("/"):
                current_path += component
                if is_valid_repo(current_path, self.base_path, explicit_scm=self.SCM):
                    return current_path
                current_path += "/"

        return repo_name

    def _get_action(self, environ):
        return "pull" if environ["REQUEST_METHOD"] in self.READ_ONLY_COMMANDS else "push"

    def _should_use_callback_daemon(self, extras, environ, action):
        # only PUT & MERGE command triggers hooks, so we don't want to start
        # hooks server too many times. POST however starts the svn transaction
        # so we also need to run the init of callback daemon of POST
        if environ["REQUEST_METHOD"] not in self.READ_ONLY_COMMANDS:
            return True
        return False

    def _create_wsgi_app(self, repo_path, repo_name, config):
        if self._is_svn_enabled():
            return SimpleSvnApp(config)
        # we don't have http proxy enabled return dummy request handler
        return DisabledSimpleSvnApp(config)

    def _is_svn_enabled(self):
        return ConfigGet().get_bool("vcs.svn.proxy.enabled")

    def _create_config(self, extras, repo_name, scheme="http"):
        server_url = ConfigGet().get_str("vcs.svn.proxy.host")
        server_url = server_url or self.DEFAULT_HTTP_SERVER

        extras["subversion_http_server_url"] = server_url
        return extras
