<?php

namespace Breakdance\Forms;

use Breakdance\Forms\Actions\Action;
use Breakdance\Forms\Actions\ActionProvider;
use function Breakdance\AJAX\get_nonce_key_for_ajax_requests;
use function Breakdance\Forms\Render\getFieldAttributes;
use const Breakdance\Filesystem\PHP_FILE_UPLOAD_ERROR_MESSAGES;

/**
 * @param int $postId
 * @param int $formId
 * @param FormUserSubmittedContents $fields
 * @return FormSuccess|FormError|FormError[]
 */
function handleSubmission($postId, $formId, $fields)
{
    $settings = getFormSettings($postId, $formId);

    if (!$settings) {
        return [
            'type' => 'error',
            'message' => 'An unexpected error occurred.'
        ];
    }

    $actions = getFormActions($settings);
    $form    = getFormData($settings['form']['fields'], $fields);
    $successMessage = $settings['form']['success_message'];
    $errorMessage = $settings['form']['error_message'];

    // Metadata
    $ip        = (string) ($_SERVER['REMOTE_ADDR'] ?? null);
    $referer   = wp_get_referer();
    $userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? null);
    $userId = get_current_user_id();

    // User access
    $breakdancePermissions = \Breakdance\Permissions\getUserPermission();
    $hasFullAccess = $breakdancePermissions && $breakdancePermissions['slug'] === 'full';

    // Validate honeypot field
    $csrfDisabled = $settings['advanced']['csrf_disabled'] ?? false;
    if (!$csrfDisabled) {
        if (!array_key_exists('csrfToken', $fields) || !is_string($fields['csrfToken']) || !wp_verify_nonce($fields['csrfToken'], get_nonce_key_for_ajax_requests())) {
            return [
                'type' => 'error',
                'message' => $hasFullAccess ? 'Invalid CSRF token' : $errorMessage
            ];
        }
        unset($fields['csrfToken']);
    }

    // reCAPTCHA v3 challenge
    $recaptchaEnabled = $settings['advanced']['recaptcha']['enabled'] ?? false;
    if ($recaptchaEnabled) {
        $token   = (string) ($_POST['recaptcha_token'] ?? null);
        if (!$token) {
            return [
                'type' => 'error',
                'message' => $hasFullAccess ? '[reCAPTCHA] Error retrieving token.' : $errorMessage
            ];
        }
        $verified = \Breakdance\Forms\Recaptcha\verify($token, $ip, 'breakdance_submit', $settings['advanced']['recaptcha']['api_key_input']['apiKey']);

        if (!$verified) {
            return [
                'type' => 'error',
                'message' => $hasFullAccess ? '[reCAPTCHA] Invalid challenge. Please reload and try again.' : $errorMessage
            ];
        }
    }

    // Validate honeypot field
    $honeypotEnabled = $settings['advanced']['honeypot_enabled'] ?? false;
    if ($honeypotEnabled) {
        if (array_key_exists('hpname', $fields) && !empty($fields['hpname'])) {
            // failed the honeypot test. Ignore this
            // submission but return a success response
            return [
                'type' => 'success',
                'message' => $successMessage,
            ];
        }
    }

    $uploads = [];
    if (array_key_exists('fields', $_FILES)) {
        /** @psalm-suppress MixedArgument */
        $uploads = getFilesNormalized($_FILES['fields']);
    }

    // Validate form. Validators available: Required, email, and files.
    $validation = validateFormData($form, $uploads);

    if (is_wp_error($validation)) {
        /** @psalm-suppress PossiblyInvalidMethodCall */
        return [
            'type' => 'error',
            'message' => $validation->get_error_message()
        ];
    }

    // Upload files if any is present. Runs after all validations
    // We don't want to allow spam files in our server.
    $files = handleUploadedFiles($formId, $uploads, $settings);
    $storeUploadedFiles = $settings['actions']['store_submission']['store_files'] ?? false;
    if (!empty($files) && $storeUploadedFiles) {
        // If we have files, assign the URLs to
        // the value for both $form and $extra
        foreach ($form as &$field) {
            $fieldId = getIdFromField($field);
            if ($field['type'] === 'file' && array_key_exists($fieldId, $files)) {
                $commaSeparatedFileUrls = implode(', ', array_map(static function($file) use ($postId, $formId, $fieldId) {
                    return getSecureFileUrl($postId, $formId, $fieldId, $file['url'], false);
                }, $files[$fieldId]));
                $fields[$fieldId] =  $commaSeparatedFileUrls;
                $field['value'] =  $commaSeparatedFileUrls;
            }
        }
        unset($field);
    }

    // Run all actions set for this form
    /** @var FormExtra $extra */
    $extra = [
        'formId'  => $formId,
        'postId'  => $postId,
        'fields'   => $fields,
        'uploads'    => $uploads,
        'files'    => $files,
        'ip'      => $ip,
        'referer' => $referer,
        'userAgent' => $userAgent,
        'userId' => $userId,
    ];

    $response = executeActions($actions, [$form, $settings, $extra]);
    /** @var int|null $storedId */
    $storedId = $response['store_submission']['id'] ?? null;

    if ($storedId) {
        unset($response['store_submission']); // We don't care for the 'store submission' action.
        \Breakdance\Forms\Submission\saveActionsLog($storedId, $response);
    } else {
        // If there is no stored submission, delete any uploaded files
        // If there is a stored submission, the stored submission action
        // will handle any necessary file cleanup
        cleanUpFiles($files);
    }

    // Remove context data from the request response
    // we don't want to return this to the user
    foreach ($response as &$responseItem) {
        unset($responseItem['context']);
    }
    unset($responseItem);

    // Show errors if any is present and user is admin
    $actionsErrors = getActionsErrors($response);
    if (!empty($actionsErrors)) {
        if ($hasFullAccess) {
            // Show action errors if user is admin
            return $actionsErrors;
        }
        return [
            'type' => 'error',
            'message' => [$errorMessage]
        ];
    }

    // Otherwise return a success message.
    return [
        'type' => 'success',
        'message' => $successMessage,
    ];
}

/**
 * @param array|string $x
 * @return mixed
 */
function recursive_strip_tags($x) {
    if (is_array($x)) {
        return array_map('\Breakdance\Forms\recursive_strip_tags', $x);
    } else {
        return strip_tags($x);
    }
}


/**
 * Run a give list of actions
 * @param Action[] $actions
 * @param array $args
 * @return array<string, ActionSuccess|ActionError>
 */
function executeActions($actions, $args)
{
    $responses = [];
    foreach ($actions as $action) {
        /** @var ActionSuccess|ActionError $output */
        $output = call_user_func_array([$action, 'run'], $args);
        $output['context'] = $action->getContext();
        $output['executed_at'] = current_time( \DateTimeInterface::ATOM );
        /** @psalm-suppress MixedAssignment */
        $responses[$action::slug()] = $output;
    }
    return $responses;
}

/**
 * Get actions errors
 * @param array<array-key, ActionSuccess|ActionError> $responses
 * @return ActionError[]
 */
function getActionsErrors($responses)
{
    /** @var ActionError[] $errors */
    $errors = array_values(array_filter(array_map(static function($response, $slug) {
        if ($response['type'] !== 'error') {
            return false;
        }
        $action = ActionProvider::getInstance()->getActionBySlug((string) $slug);
        $actionName     = $action ? $action->name() : $slug;
        $response['message'] = "[$actionName]: " . $response['message'];
        return $response;
    }, $responses, array_keys($responses))));

    return $errors;
}

/**
 * @param int $postId
 * @param int $formId
 * @return FormSettings|null
 */
function getFormSettings($postId, $formId)
{
    $node = \Breakdance\Render\getNodeById($postId, $formId);

    if (!$node) return null;

    /** @var array{content: FormSettings, design: FormDesign, settings: array} $props */
    $props = $node['data']['properties'];

    /** @var FormSettings|null */
    return $props['content'];
}

/**
 * Get available actions for the given settings
 * @param FormSettings $settings
 * @return Action[]
 */
function getFormActions($settings)
{
    $enabledActions = $settings['actions']['actions'] ?? false;

    if (!$enabledActions) return [];

    $allActions = ActionProvider::getInstance()->getActions();

    return array_values(
        array_filter(
            $allActions,
            function (Action $action) use ($enabledActions) {
                return in_array($action::slug(), $enabledActions);
            }
        )
    );
}

/**
 * @param mixed $value
 * @param array $sanitizers
 * @return mixed|null
 */
function _sanitize($value, $sanitizers)
{
    if (is_array($value)) {
        return array_map(
            /**
             * @param mixed $itemVal
             * @return mixed
             */
            function ($itemVal) use ($sanitizers) {
                return _sanitize($itemVal, $sanitizers);
            },
            $value
        );
    }

    return array_reduce(
        $sanitizers,
        /**
         * @param mixed $val
         * @param \Closure $sanitizer
         * @return mixed
         */
        function ($val, $sanitizer) {
            return $sanitizer($val);
        },
        $value
    );
}

/**
 * @param mixed $value
 * @param string $type
 * @return mixed
 */
function sanitizeValue($value, $type)
{
    $sanitizers = [
        'email'    => ['sanitize_text_field', 'sanitize_email'],
        'text'     => ['sanitize_text_field'],
        'textarea' => ['sanitize_textarea_field']
    ];

    $default = ['sanitize_text_field'];

    return _sanitize($value, $sanitizers[$type] ?? $default);
}

/**
 * @param array $value
 * @return string
 */
function getCommaDelimitedValue($value)
{
    return implode(', ', $value);
}

/**
 * Merge labels and user submitted data together.
 * @param StoredFormField[] | null $storedFields
 * @param FormUserSubmittedContents $values
 * @return FormData
 */
function getFormData($storedFields, $values)
{
    if (!is_array($storedFields)) {
        return [];
    }

    return array_map(function ($storedField) use ($values) {
        $field = getFieldAttributes($storedField);
        /** @var mixed $original */
        $fieldId = \Breakdance\Forms\getIdFromField($field);
        $original = array_key_exists($fieldId, $values) ? $values[$fieldId] : '';
        /** @var string $value */
        $value = is_array($original) ? getCommaDelimitedValue($original) : $original;

        return array_merge($field, [
            'value' => $value,
            'originalValue' => $original
        ]);
    }, $storedFields);
}

/**
 * @param FormData $form
 * @param NormalizedUploadedFiles $files
 * @return \WP_Error|true
 */
function validateFormData($form, $files)
{
    $bag = new \WP_Error();
    $fileFieldIds = [];

    foreach ($form as $field) {
        $required = $field['advanced']['required'] ?? false;
        if($required && array_key_exists('conditional', $field['advanced']) && $field['advanced']['conditional'] === true) {
            $required = isConditionalFieldRequired($field, $form);
        }

        // Required fields
        if ($required && $field['type'] !== 'file' && empty($field['value'])) {
            $bag->add('required', sprintf("%s is required.", $field['label']));
            continue; // Bail out from other validations if the value is undefined
        }

        // Email Addresses
        if ($required && $field['type'] === 'email' && !is_email($field['value'])) {
            $bag->add('invalid_email', "Please enter a valid email address.");
        }

        if ($field['type'] === 'file') {
            $fieldId = \Breakdance\Forms\getIdFromField($field);
            // File Upload
            $fileFieldIds[] = $fieldId;
            /** @var _FILE[] $fieldFiles */
            $fieldFiles = $files[$fieldId] ?? [];

            if ($required && !$fieldFiles) {
                $bag->add('required_file', sprintf("%s is required.", $field['label']));
            }

            if ($fieldFiles) {
                $maxNumOfFiles = $field['max_number_of_files'] ?? 1;
                $maxFileSizeInMB = $field['max_file_size'] ?? 10;
                $maxFileSizeInBytes = $maxFileSizeInMB * pow(1024, 2);
                $allowedFileTypes = $field['allowed_file_types'] ?? [];

                if (count($fieldFiles) > $maxNumOfFiles) {
                    $bag->add('file_max', sprintf("%s may not have more than %s file(s).", $field['label'], $maxNumOfFiles));
                }

                foreach ($fieldFiles as $file) {
                    if ($file['error'] === UPLOAD_ERR_NO_FILE && !$required) {
                        continue;
                    }
                    if ($file['error'] !== UPLOAD_ERR_OK) {
                        $bag->add('file_error', PHP_FILE_UPLOAD_ERROR_MESSAGES[$file['error']]);
                    }

                    $size = filesize($file['tmp_name']);
                    $allowedMimeTypes = array_filter(get_allowed_mime_types(), static function($mimeType) use ($allowedFileTypes) {
                        return in_array($mimeType, $allowedFileTypes);
                    });
                    /** @var array{ext: string|false, type: string|false} $isTypeValid */
                    $isTypeValid = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowedMimeTypes);

                    if ($size > $maxFileSizeInBytes) {
                        $bag->add('file_size', sprintf("%s must be less than %s megabytes.", $field['label'], $maxFileSizeInMB));
                    }

                    if ($isTypeValid['ext'] === false || $isTypeValid['type'] === false) {
                        $parseFileTypes = implode(', ', preg_replace('(.*/)', '', $allowedFileTypes));
                        $bag->add('file_type', sprintf("%s must be a file of type: %s", $field['label'], $parseFileTypes));
                    }
                }
            }
        }
    }

    $unexpectedFiles = array_diff(array_keys($files), $fileFieldIds);
    if (!empty($unexpectedFiles)) {
         foreach ($unexpectedFiles as $fieldId) {
             $bag->add('unexpected_file', sprintf('Unexpected file with id: %s', $fieldId));
         }
    }

    if ($bag->has_errors()) {
        return $bag;
    }

    return true;
}

/**
 * @param StoredFormField $field
 * @param FormData $form
 * @return bool
 */
function isConditionalFieldRequired($field, $form)
{
    $condition = $field['advanced']['condition'] ?? [];
    if (isset($condition['field'], $condition['operand'], $condition['value'])) {
        /** @var array<array-key, FormFieldWithValue> $allFieldsByID */
        $allFieldsByID = [];
        foreach ($form as $formField) {
            $allFieldsByID[$formField['advanced']['id']] = $formField;
        }
        /**
         *  have to define this here as psalm
         *  doesn't support recursive type definitions
         * @var StoredFormField $conditionField
         */
        $conditionField = $condition['field'];
        $conditionFieldId = getIdFromField($conditionField);
        if (!array_key_exists($conditionFieldId, $allFieldsByID)) {
            return false;
        }
        $value = $allFieldsByID[$conditionFieldId]['value'];
        return shouldBeRequired($condition['value'], $condition['operand'], $value);
    }

    return false;
}

/**
 * The below is replicated in JS in forms/shared/js/shared.js
 * so if you change it here, you need to change it there too.
 *
 * @param string $aValue
 * @param string $operand
 * @param string $bValue
 * @return bool
 */
function shouldBeRequired($aValue, $operand, $bValue) {
    if ($operand === "equals") {
        return $aValue == $bValue;
    }

    if ($operand === "not equals") {
        return $aValue != $bValue;
    }

    if ($operand === "is one of") {
        $bValueArray = explode($bValue, ',');
        return count(array_filter($bValueArray, static function($bValue) use ($aValue) { return trim($bValue) == $aValue; })) > 0;
    }

    if ($operand === "is none of") {
        $bValueArray = explode($bValue, ',');
        return count(array_filter($bValueArray, static function($bValue) use ($aValue) { return trim($bValue) == $aValue; })) === 0;
    }

    if ($operand === "contains") {
        if ($aValue && $bValue) {
            return stripos($aValue, $bValue) !== false;
        }
        return false;
    }

    if ($operand === "does not contain") {
        if ($aValue && $bValue) {
            return stripos($aValue, $bValue) !== false;
        }
        return true;
    }

    return false;
}

add_filter('posts_join', 'Breakdance\Forms\submission_search_join');
/**
 * @param string $join
 * @return string
 */
function submission_search_join($join)
{
    global $pagenow, $wpdb;

    $postType = (string) ($_GET['post_type'] ?? '');

    if ($pagenow === 'edit.php'  && 'breakdance_form_res' === $postType && !empty($_GET['s']) && is_admin()) {
        /** @psalm-suppress MixedPropertyFetch */
        $join .= 'LEFT JOIN ' . (string) $wpdb->postmeta . ' ON ' . (string) $wpdb->posts . '.ID = ' . (string) $wpdb->postmeta . '.post_id ';
    }
    return $join;
}

add_filter('posts_where', 'Breakdance\Forms\submission_search_where');
/**
 * @param string $where
 * @return string
 */
function submission_search_where($where)
{
    global $pagenow, $wpdb;
    $postType = (string) ($_GET['post_type'] ?? '');

    if ($pagenow === 'edit.php'  && 'breakdance_form_res' === $postType && !empty($_GET['s']) && is_admin()) {
        /** @psalm-suppress MixedPropertyFetch */
        $where = preg_replace(
            "/\(\s*" . (string) $wpdb->posts . ".post_title\s+LIKE\s*(\'[^\']+\')\s*\)/",
            "(" . (string) $wpdb->posts . ".post_title LIKE $1) OR (" . (string) $wpdb->postmeta . ".meta_value LIKE $1)", $where
        );
    }
    return $where;
}

add_filter('posts_distinct', 'Breakdance\Forms\submission_search_distinct');
/**
 * @param string $where
 * @return string
 */
function submission_search_distinct($where)
{
    global $pagenow;
    $postType = (string) ($_GET['post_type'] ?? '');

    if ($pagenow === 'edit.php'  && 'breakdance_form_res' === $postType && !empty($_GET['s']) && is_admin()) {
        return "DISTINCT";
    }
    return $where;
}
