<?php

namespace onespace\tools\widgets\dropzone;

use Exception;
use Yii;
use yii\base\Widget;
use yii\helpers\FileHelper;
use yii\helpers\Html;
use yii\web\UploadedFile;
use yii\web\View;

/**
 * Insert a Dropzone file uploader as required.
 * 
 * Widget wrapper for Dropzone.
 * 
 * @see https://docs.dropzone.dev/
 * 
 * @author Gareth Palmer <gareth@one-space.co.za>
 */

final class Dropzone extends Widget {

    /**
     * REQUIRED. The ID of the uploader form
     *
     * @var string  $id
     * 
     * @access  public
     */

    public string $id;

    /**
     * REQUIRED. The URI that will handle 
     *
     * @var string  $action
     * 
     * @access  public
     */

    public string $action;

    /**
     * Whether you want files to be uploaded in chunks to your server. This can't be used in combination with 
     * uploadMultiple. See [chunksUploaded](#config-chunksUploaded) for the callback to finalise an upload.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#chunking
     * 
     * @var bool    $chunking   Default: true
     * 
     * @access  public
     */

    public bool $chunking =  true;

    /**
     * If chunking is true, then this defines the chunk size in bytes.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#chunkSize
     * 
     * @var int $chunkSize  Default: 2000000
     * 
     * @access  public
     */

    public int $chunkSize =  2000000;

    /**
     * If chunking is enabled, this defines whether **every** file should be chunked, even if the file 
     * size is below chunkSize. This means, that the additional chunk form data will be submitted and 
     * the chunksUploaded callback will be invoked.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#forceChunking
     * 
     * @var bool    $forceChunking  Default: false
     * 
     * @access  public
     */

    public bool $forceChunking =  false;

    /**
     * Whether a chunk should be retried if it fails.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#retryChunks
     * 
     * @var bool    $retryChunks  Default: false
     * 
     * @access  public
     */

    public bool $retryChunks =  false;

    /**
     * If `retryChunks` is true, how many times should it be retried.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#retryChunksLimit
     * 
     * @var int $retryChunksLimit  Default: 3
     * 
     * @access  public
     */

    public int $retryChunksLimit =  3;

    /**
     * How many file uploads to process in parallel (See the Enqueuing file uploads documentation section for more info)
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#parallelUploads
     * 
     * @var int $parallelUploads  Default: 3
     * 
     * @access  public
     */

    public int $parallelUploads =  3;

    /**
     * If `true`, the individual chunks of a file are being uploaded simultaneously.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#parallelChunkUploads
     * 
     * @var bool    $parallelChunkUploads   Default: false
     * 
     * @access  public
     */

    public bool $parallelChunkUploads =  false;

    /**
     * The timeout for the XHR requests in milliseconds (since `v4.4.0`). If set to 0, no timeout is going to be set.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#timeout
     * 
     * @var int $timeout    Default: 120000
     * 
     * @access  public
     */

    public int $timeout =  120000;

    /**
     * The maximum filesize (in bytes) that is allowed to be uploaded.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#maxFilesize
     * 
     * @var int $maxFilesize    Default: 5000000000
     * 
     * @access  public
     */

    public int $maxFilesize =  5000000000;

    /**
     * If not null defines how many files this Dropzone handles. If it exceeds, the event `maxfilesexceeded` 
     * will be called. The dropzone element gets the class `dz-max-files-reached` accordingly so you can provide visual feedback.
     * 
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#maxFiles
     * 
     * @var int|null    $maxFiles   Default: null
     * 
     * @access  public
     */

    public ?int $maxFiles = null;

    /**
     * ## OPTIONAL
     * 
     * Set a specific type of file that can be uploaded. Leave null to remove this limit.
     * 
     * Must be parsed as a MIME-TYPE or as a extension (eg .docx).
     * 
     * Multiple values must be seperated with a comma `,`
     * 
     * @note    There is an interesting bug, driving from the browser in the Windows context:
     *          If you wish to limit to a CSV file, the MIME-TYPE is `text/csv`. If this property
     *          is set to `text/csv` and you have MS Office installed, it'll be detected as 
     *          `application/vnd.ms-excel`. Instead pass `.csv` to limit to this MIME-TYPE.
     *          See: https://christianwood.net/posts/csv-file-upload-validation/ for more info.
     * 
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
     * @see https://docs.dropzone.dev/configuration/basics/configuration-options#acceptedFiles
     *
     * @var string|null $acceptedFiles  Default: null
     * 
     * @access  public
     */

    public ?string $acceptedFiles = null;

    /**
     * ## OPTIONAL
     * 
     * Any other params to add to the upload form element.
     *
     * @var array   $options
     * 
     * @access  public
     */

    public array $options = [];

    /**
     * ## OPTIONAL
     * 
     * Any hidden inputs to add to the upload form.
     *
     * @var array   $hiddenInputs
     * 
     * @access  public
     */

    public array $hiddenInputs = [];

    /**
     * ## OPTIONAL
     * 
     * Whether or not to bootstrap the dropzone into a form. This will set `$this->hook` if not
     * overwritten.
     * 
     * You should set this to false if you intend to imbed the Dropzon within another `<form>` tag.
     * 
     * @var bool    $useForm    Default: true
     * 
     * @access  public
     */

    public bool $useForm = true;

    /**
     * ## OPTIONAL
     * 
     * You can set the hook tag into which the dropzone will load manually. If not set set, if will autoset to
     * `<form>` if `$this->useForm` is set to `true` and `<div>` if `$this->useForm` is set to `false`.
     * 
     * @var string  $hook
     * 
     * @access  public
     */

    public string $hook;

    /**
     * Show debug upload status updates to the user's console.
     * 
     * @var bool    $debug  Default: false
     * 
     * @access  public
     */

    public bool $debug = false;

    /**
     * The csrf id.
     * 
     * @var string  $csrf
     * 
     * @access  public
     */

    public string $csrf = '_csrf';


    /**
     * {@inheritDoc}
     */

    public function init() {
        parent::init();

        if (!isset($this->id)) {
            throw new Exception("You must specify an element id from which the dropzone must hook.");
        }
        if (!isset($this->action)) {
            throw new Exception("You must specify an action, the URI which will handle any files uploaded.");
        }

        if (!isset($this->hook)) {
            if ($this->useForm) {
                $this->hook = 'form';
            } else {
                $this->hook = 'div';
            }
        }


        if (isset($this->options['id'])) {
            $this->id = $this->options['id'];
            unset($this->options['id']);
        }
        if (isset($this->options['class'])) {
            $this->options['class'] = 'dropzone ' . $this->options['class'];
        } else {
            $this->options['class'] = 'dropzone';
        }

        $this->options['enctype'] = 'multipart/form-data';
        $this->options['method'] = 'POST';
        $this->options['action'] = $this->action;

        $this->hiddenInputs[$this->csrf] ??= Yii::$app->request->csrfToken;

        $debug = "0";
        if ($this->debug) {
            $debug = "1";
        }

        $view = $this->getView();
        $view->registerCss(file_get_contents(Yii::getAlias('@vendor/enyo/dropzone/dist/min/dropzone.min.css')));
        $view->registerJs(file_get_contents(Yii::getAlias('@vendor/enyo/dropzone/dist/min/dropzone.min.js')), View::POS_HEAD);

        $accept = is_null($this->acceptedFiles) ? 'NULL' : $this->acceptedFiles;
        $maxFiles = is_null($this->maxFiles) ? 'NULL' : $this->maxFiles;
        $rand_id = str_replace('-', '_', Yii::$app->security->generateRandomString(8));

        $insertParams = json_encode([]);
        if (!$this->useForm) {
            $insertParams = json_encode($this->hiddenInputs);
        }

        $view->registerJs(<<<JS
        const dzDebug{$rand_id} = $debug;
        const chunking{$rand_id} = '$this->chunking' == '1' ? true : false;
        const forceChunking{$rand_id} = '$this->forceChunking' == '1' ? true : false;
        const retryChunks{$rand_id} = '$this->retryChunks' == '1' ? true : false;
        const parallelChunkUploads{$rand_id} = '$this->parallelChunkUploads' == '1' ? true : false;
        const accept{$rand_id} = '$accept' == 'NULL' ? null : '$accept';
        const maxFiles{$rand_id} = '{$maxFiles}' == 'NULL' ? null : {$maxFiles};
        const insertParams{$rand_id} = JSON.parse('$insertParams');
        Dropzone.autoDiscover = false;
        const dropzone{$rand_id} = new Dropzone("{$this->hook}#{$this->id}", {
            url: '{$this->action}',
            chunking: chunking{$rand_id},
            chunkSize: $this->chunkSize,
            forceChunking: forceChunking{$rand_id},
            retryChunks: retryChunks{$rand_id},
            retryChunksLimit: $this->retryChunksLimit,
            parallelUploads: $this->parallelUploads,
            parallelChunkUploads: parallelChunkUploads{$rand_id},
            timeout: $this->timeout,
            maxFilesize: $this->maxFilesize,
            acceptedFiles: accept{$rand_id},
            maxFiles: maxFiles{$rand_id},
            init: function () {
                this.on("sending", function (file, xhr, formData) {
                    for (const key in insertParams{$rand_id}) {
                        const value = insertParams{$rand_id}[key];
                        formData.append(key, value);
                    }
                    xhr.onreadystatechange = function () {
                        if (this.readyState == 4) {
                            if (dzDebug{$rand_id} == 1) {
                                console.warn('While Sending:', JSON.parse(this.responseText));
                            }
                        }
                    };
                    xhr.ontimeout = (function () {
                        alert('Error: Server Timeout');
                    });
                }).on("success", function (res) {
                    let response = JSON.parse(res.xhr.response);
                    if (dzDebug{$rand_id} == 1) {
                        console.warn('On Success', response);
                    }
                    if (response.status == "error") {
                        if (dzDebug{$rand_id} == 1) {
                            alert(response.info);
                        } else {
                            alert('Upload failed');
                        }
                    }
                }).on("error", function (file, response) {
                    if (response.message !== undefined) {
                        response = reponse.message
                    }
                    if (dzDebug{$rand_id} == 1) {
                        alert("Error: " + response);
                    } else {
                        alert('Upload failed');
                    }
                });
            }
        });
        JS, View::POS_END);
    }


    /**
     * {@inheritDoc}
     * 
     * @return string
     * 
     * @access  public
     */

    public function run(): string {
        $html = '';
        $html .= parent::run();

        if (!is_null($this->maxFiles)) {
            $html .= Html::tag('div', "You may only upload a total of {$this->maxFiles} file(s)", [
                'class' => 'text-right',
            ]);
        }
        $html .= "<{$this->hook} id='{$this->id}'";
        foreach ($this->options as $key => $value) {
            $html .= " {$key}='{$value}'";
        }
        $html .= ">";
        if ($this->useForm) {
            foreach ($this->hiddenInputs as $name => $value) {
                $html .= "<input type='hidden' name='{$name}' value='{$value}'>";
            }
        }
        $html .= "</{$this->hook}>";
        return $html;
    }


    /**
     * Method for handling uploads. It should be called in your upload handling controller action.
     * 
     * It automatically handles chunked uploads if that is required.
     * 
     * @param   string  $upload_dir
     * 
     * @return string
     * 
     * @access  public
     */

    public static function handle_upload(string $upload_dir): string {
        /**
         * Ensure the upload dir is the correctly formatted.
         * 
         * @example /var/www/html/upload/directory -> /var/www/html/upload/directory/
         */
        $upload_dir = rtrim($upload_dir, '/') . '/';

        /**
         * Create the upload directory if it does not exist.
         */
        if (!file_exists($upload_dir)) {
            mkdir($upload_dir, 0777, true);
        }

        /**
         * Perform the initial upload to /tmp
         */
        $upload = UploadedFile::getInstanceByName('file');

        /**
         * Determine if the upload is being chunked.
         */
        $request = Yii::$app->request;
        $chunkNumber = $request->post('dzchunkindex');

        if ($request->post('dzuuid') !== null) {
            /**
             * CHUNKED
             * 
             * Start by setting a temporary chunk file.
             */
            $chunksPath = $upload_dir;
            $tempFilename = $request->post('dzuuid');
            $tempFilePath = $chunksPath . $tempFilename . '_' . $chunkNumber;

            /**
             * Save the chunk temporarily
             */
            $upload->saveAs($tempFilePath);

            /**
             * Determine if this chunk is the last run
             */
            if ((int)$request->post('dzchunkindex') === (int)($request->post('dztotalchunkcount') - 1)) {
                /**
                 * Process and merge the files and write it to the desired final file.
                 */
                $file_name = $upload_dir . $upload->name;
                $chunkFiles = FileHelper::findFiles($chunksPath);
                natcasesort($chunkFiles);
                $finalFile = fopen($file_name, 'ab');
                foreach ($chunkFiles as $file) {
                    if (!str_contains($file, $tempFilename)) {
                        continue;
                    }
                    $chunkData = file_get_contents($file);
                    fwrite($finalFile, $chunkData);
                    unlink($file); // Delete the chunk file
                }
                fclose($finalFile);
                $response = [
                    'code' => 200,
                    'status' => 'Upload Complete',
                    'path' => $file_name,
                    'fileName' => $upload->name,
                ];
            } else {
                $response = [
                    'code' => 206,
                    'status' => 'Partial chunk upload complete',
                    'chunk_file' => $tempFilePath,
                ];
            }
            return json_encode($response);
        } else {
            /**
             * NOT CHUNKED
             * 
             * Save the file and place it in the desired directory.
             */
            $fileLocation = $upload->baseName . '.' . $upload->extension;
            if (!$upload->saveAs($upload_dir . $fileLocation, false)) {
                $response = ['code' => 500];
            } else {
                $response = [
                    'code' => 200,
                    'status' => 'Upload Complete',
                    'path' => $upload_dir . $fileLocation,
                    'fileName' => $fileLocation,
                ];
            }
            return json_encode($response);
        }
    }
}
