<?php

namespace Clonable\Services;

use Clonable\Helpers\Functions;
use Clonable\Models\Site;
use Clonable\Objects\CircuitBreaker;
use Clonable\Objects\CurlBuilder;
use Clonable\Objects\ClonableResponse;
use Exception;

class SubfolderService {
    private $circuit_breaker;

    private const SEMAPHORE_UPSTREAM = 62342001;
    private const SEMAPHORE_UPSTREAM_QUEUE = 62342002;

    public function __construct() {
        if (get_option('clonable_subfolder_service_enabled', 'on') !== 'on') {
            return;
        }

        $languages = $this->get_subfolder_languages();
        if (empty($languages) && !is_admin()) {
            // just even bother with trying hook registration when there are no subfolder clones.
            // should improve stability for non subfolder installations.
            return;
        }

        $this->circuit_breaker = new CircuitBreaker();
        add_action('wp_enqueue_scripts', [$this, 'enqueue_sfm_script']);
        add_action('plugins_loaded', array($this, 'subfolder_request_intercept'), 2);
    }

    public function enqueue_sfm_script()
    {
        wp_enqueue_script('clonable-subfolder-ajax-middleware', '/clonable-cgi/js/sfm', array('jquery'));
    }

    /**
     * @return array
     */
    private function get_subfolder_languages() {
        $response = get_option("clonable_site");
        if (empty($response)) {
            return array();
        }

        $site = new Site($response);
        return $site->get_subfolders();
    }

    public function subfolder_request_intercept() {
        $server_data = $_SERVER; // this fixes some linting rules

        $subfolder_languages = $this->get_subfolder_languages();
        $is_clonable_page = false;

        $request_url = explode('?', $server_data['REQUEST_URI'], 2);
        $request_path = $request_url[0] ?? '/';
        if ($request_path == null || $request_path == '/' || !empty($server_data['HTTP_CLONABLE_CLONE_SUBFOLDER'])) {
            return;
        }

        // Check if this request is for a subfolder
        foreach ($subfolder_languages as $subfolder_language) {
            if (Functions::str_starts_with($request_path, $subfolder_language['subfolder_clone'])) {
                $is_clonable_page = true;
                break;
            }

            // Intercept cases where eg /en is requested. Redirect those to /en/
            $trimmed_lang = rtrim($subfolder_language['subfolder_clone'], '/');

            if ($request_path === $trimmed_lang) {
                if ($subfolder_language['with_trailing_slash']) {
                    wp_redirect($subfolder_language['subfolder_clone'], 302, 'WordPress - Clonable');
                    exit();
                } else {
                    $is_clonable_page = true;
                    break;
                }
            }
        }

        $request_path = $request_path . (count($request_url) === 2 ? "?{$request_url[1]}" : "");
        if ($is_clonable_page) {
            do_action('clonable_before_page_translation', $request_path);

            // Check the circuit breaker before actually sending the request to Clonable
            if ($this->circuit_breaker->isOpen()) {
                $this->circuit_breaker_response();
                // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
                exit;
            }

            $headers = getallheaders();
            $request_method = $server_data['REQUEST_METHOD'];
            $request_url = Functions::get_root_domain() . $request_path;
            $post_body = $this->retrieve_body_content($server_data['CONTENT_TYPE'] ?? null);
            $clonable_response = $this->make_curl_request($request_url, $headers, $request_method, $post_body);

            $this->circuit_breaker->handle(!$clonable_response->is_connection_error());
            if ($this->circuit_breaker->isOpen()) {
                $this->circuit_breaker_response();
                // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
                exit; // exit current script, otherwise the page gets loaded twice at the bottom of the page
            }

            // Set status code
            status_header($clonable_response->get_code());

            // Forward headers, except those that can cause issues
            foreach ($clonable_response->get_headers() as $key => $values) {
                if (!(strtolower($key) == 'content-length' || strtolower($key) == 'content-encoding' || strtolower($key) == 'transfer-encoding')) {
                    foreach ($values as $value) {
                        $replace_header = count($values) <= 1;
                        header($key . ':' . $value, $replace_header);
                    }
                }
            }

            // Clear output buffers to prevent html manipulation by plugins. We want to cleanly pass through the response.
            while(ob_get_level()) {
                $success = ob_end_clean();
                if (!$success) {
                    // break if the output buffer cannot be deleted.
                    break;
                }
            }

            // Send output and exit.
            // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
            echo $clonable_response->get_body();
            // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped

            do_action('clonable_after_page_translation', $request_path);
            exit; // exit current script, otherwise the page gets loaded twice at the bottom of the page
        }

        if (Functions::str_starts_with($request_path, "/clonable-cgi/")) {
            if (Functions::str_starts_with($request_path, "/clonable-cgi/js/")) {
                header("Content-type: application/javascript");
            } else if (Functions::str_starts_with($request_path, "/clonable-cgi/css/")) {
                header("Content-type: text/css");
            }
            header("Cache-Control: public, max-age=86400");

            while(ob_get_level()) {
                $success = ob_end_clean();
                if (!$success) {
                    // break if the output buffer cannot be deleted.
                    break;
                }
            }
            exit;
        }
        // Don't need an else or return.
        // If it's not a custom route or the server parameters cannot be gotten, then use the standard response of the request.
    }

    private function circuit_breaker_response() {
        status_header(504);
        echo '<h1>Bad Gateway</h1><br/>';
        echo '<p>The server, while acting as a gateway or proxy, did not get a response in time from the upstream server that it needed in order to complete the request.</p>';
        if (Functions::can_log_sensitive()) {
            echo "<br/>";
            echo "<h2>Admin only:</h2>";
            $this->circuit_breaker->debug();
        }
    }

    /**
     * @param $content_type
     * @return false|string|null
     */
    private function retrieve_body_content($content_type) {
        if (Functions::str_starts_with($content_type, "multipart/form-data;")) {
            return $this->build_data_files($content_type);
        } else {
            return (file_get_contents('php://input') ?? null);
        }
    }

    /**
     * Parses the individual
     *
     * @param $delimiter
     * @param $name
     * @param $content
     * @return string
     */
    private function build_content_field($delimiter, $name, $content) {
        $eol = "\r\n";
        if (is_array($content)) {
            $sub_data = "";
            foreach ($content as $sub_name => $sub_content) {
                $sub_data .= $this->build_content_field($delimiter, "{$name}[$sub_name]", $sub_content);
            }
            return $sub_data;
        } else {
            return "--" . $delimiter . $eol
                . 'Content-Disposition: form-data; name="' . $name . "\"".$eol.$eol
                . $content . $eol;
        }
    }

    /**
     * Handle file upload arrays recursively
     * @return string
     */
    private function handle_file_array($names, $types, $tmp_names, $field_name, $delimiter, $eol) {
        $data = '';

        foreach ($names as $key => $name) {
            if (is_array($names[$key])) {
                $data .= $this->handle_file_array($names[$key], $types[$key], $tmp_names[$key], "{$field_name}[$key]", $delimiter, $eol);
            } else {
                $file_name = $names[$key];
                $file_type = $types[$key];
                $tmp_name = $tmp_names[$key];
                $data .= $this->build_individual_data_file($delimiter, "{$field_name}[]", $eol, $file_name, $file_type, $tmp_name);
            }
        }

        return $data;
    }

    /**
     * Builds a multipart form-data request, based on the input fields.
     * We have to construct this manually, because PHP does not read this data correctly.
     * For now, files are ignored by this method, but they can be added later.
     * Resource used: https://www.php.net/manual/en/features.file-upload.php
     * Resource used: https://gist.github.com/maxivak/18fcac476a2f4ea02e5f80b303811d5f
     * Resource used: https://www.php.net/manual/en/features.file-upload.post-method.php
     * @param $content_type string entire content type header of the request.
     * @param $fields
     * @return string
     */
    private function build_data_files($content_type){
        $data = '';
        $delimiter = str_replace("multipart/form-data; boundary=", "", $content_type);

        $eol = "\r\n";
        // convert post fields to multipart form-data variant
        foreach ($_POST as $name => $content) {
            $data .= $this->build_content_field($delimiter, $name, $content);
        }

        // ======== Optional file uploads ========
        // ---------------------------------------
        // For optional files, filenames can be empty.
        // Even the files themselves can be totally empty.
        // But the content-disposition and content-type data should still be added
        //
        // Example of optional file upload field data:
        // Content-Disposition: form-data; name="input_13"; filename=""
        // Content-Type: application/octet-stream
        foreach ($_FILES as $field_name => $file_data) {
            // I cannot explain the disappointment I felt when I discovered how image arrays were parsed by PHP.
            // for some reason it's not an array of images, but multiple array of array grouped by images properties.
            if (is_array($file_data['name'])) {
                $data .= $this->handle_file_array($file_data['name'], $file_data['type'], $file_data['tmp_name'], $field_name, $delimiter, $eol);
            } else {
                $file_name = $file_data['name'];
                $file_type = $file_data['type'];
                $tmp_name = $file_data['tmp_name'];
                $data .= $this->build_individual_data_file($delimiter, $field_name, $eol, $file_name, $file_type, $tmp_name);
            }
        }

        $data .= "--" . $delimiter . "--".$eol;
        return $data;
    }

    /**
     * @param $delimiter
     * @param $field_name
     * @param $eol
     * @param $file_name
     * @param $file_type
     * @param $tmp_name
     * @return string
     */
    private function build_individual_data_file($delimiter, $field_name, $eol, $file_name, $file_type, $tmp_name) {
        $filename = "";
        if (isset($file_name)) {
            $filename = $file_name;
        }

        // build content disposition file header for multipart input (file uploads)
        $data = "--" . $delimiter . $eol
            . 'Content-Disposition: form-data; name="' . $field_name . '"; filename="' . $filename . '"' . $eol;
        if (!empty($file_type)) {
            $data .= 'Content-Type: ' . $file_type .$eol;
        } else {
            $data .= 'Content-Type: application/octet-stream' . $eol;
        }

        $data .= $eol;
        if ($tmp_name != '') {
            $file_content = file_get_contents($tmp_name);
        } else {
            $file_content = '';
        }

        if ($file_content !== false) {
            $data .= $file_content . $eol; // append file content (if it's not empty)
        }
        return $data;
    }


    /**
     * Makes the curl request to retrieve the cloned page from Clonable.
     * Return a custom Clonable response object, containing request data.
     * @param $url string the url of the page
     * @param $request_headers array all the request headers
     * @param $request_type string HTTP request method
     * @param $body string|null optional post fields
     * @return ClonableResponse
     */
    private function make_curl_request($url, $request_headers, $request_type, $body = null) {
        $curl_builder = new CurlBuilder($url);

        if ($body != null) {
            $curl_builder = $curl_builder->set_post_fields($body);
        }

        try {
            $max_timeout = get_option('clonable_max_proxy_timeout', 15);
            $site_url = str_replace(array('https://', 'http://'), '', Functions::get_root_domain());
            $curl = $curl_builder
                ->set_request_type($request_type)
                ->set_standard_headers($site_url, $max_timeout)
                ->set_header_response($headers)
                ->set_http_headers($request_headers)
                ->build();
        } catch (Exception $e) {
            curl_close($curl_builder->build());

            error_log("Clonable failed to forward the request: {$e->getMessage()}");
            $message = 'An error occurred. Please check the server logs.<br/>';
            if (Functions::can_log_sensitive()) {
                // The stacktrace/error can be shown, because the user has access to the sensitive data
                $message .=  '[ERROR]: ' . $e->getMessage();
                $message .= '<br/><br/>';
                $message .= "<pre>" . nl2br($e->getTraceAsString()) . "</pre>";
                // maybe the previous error helps with solving more complex problems.
                $message .= "<pre>" . esc_html(print_r($e->getPrevious(), true)) . "</pre>";
            }
            return new ClonableResponse($message, array(), 500, true);
        }

        // Semaphore
        if (function_exists("sem_get") && function_exists("sem_acquire") && function_exists("sem_release")) {
            $std_limit = max(2, intval(get_option("clonable_max_upstream_requests", 4)));
            $queue_limit = $std_limit + max(0, intval(get_option("clonable_max_upstream_queued", 2)));

            $semaphore = sem_get(self::SEMAPHORE_UPSTREAM, $std_limit);
            $semaphore_queue = sem_get(self::SEMAPHORE_UPSTREAM_QUEUE, $queue_limit);
            if ($semaphore !== false) {
                if (!sem_acquire($semaphore_queue, true)) {
                    return new ClonableResponse("Server is busy. Please try again soon.", ['retry-after' => ['5'], 'location' => [$url]], 307, false);
                }
                sem_acquire($semaphore);
            } else {
                $semaphore = null;
            }
        }


        // Send the request to Clonable
        $response = curl_exec($curl);
        if ($response === false) {
            $error = curl_error($curl);
            curl_close($curl); // make sure to close the request

            error_log("Clonable failed to retrieve the response: {$error}");
            $message = 'An error occurred. Please check the server logs.<br/>';
            if (Functions::can_log_sensitive()) {
                $message .= $error;
            }

            if (isset($semaphore) && isset($semaphore_queue)) {
                sem_release($semaphore);
                sem_release($semaphore_queue);
            }
            return new ClonableResponse($message, $headers ?? [], 504, str_contains($error, 'timed out'));
        }

        $status_code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        $header_size = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
        curl_close($curl); // make sure to close the request
        $body = substr($response, $header_size);
        if (!empty($headers['content-encoding']) && $headers['content-encoding'][0] === 'gzip') {
            $body = gzdecode($body);
        }

        if (isset($semaphore) && isset($semaphore_queue)) {
            sem_release($semaphore);
            sem_release($semaphore_queue);
        }
        return new ClonableResponse($body, $headers, $status_code);
    }
}