<?php

namespace App\Conductors;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class Conductor
{
    /**
     * The Conductors Model class.
     *
     * @var string|null
     */
    protected $class = null;

    /**
     * The default sorting fields of a collection. Can be an array. Supports - and + prefixes.
     *
     * @var string|array
     */
    protected $sort = "id";

    /**
     * The default collection size limit per request.
     *
     * @var integer
     */
    protected $limit = 50;

    /**
     * The maximum collection size limit per request.
     *
     * @var integer
     */
    protected $maxLimit = 100;

    /**
     * The default includes to include in a request.
     *
     * @var array
     */
    protected $includes = [];

    /**
     * The default filters to use in a request.
     *
     * @var array
     */
    protected $defaultFilters = [];

    /**
     * The conductor collection.
     *
     * @var Collection
     */
    private $collection = null;

    /**
     * The conductor query.
     *
     * @var Builder
     */
    private $query = null;


    /**
     * Split a string on commas, keeping quotes intact.
     *
     * @param string $string The string to split.
     * @return array The split string.
     */
    private function splitString(string $string)
    {
        $parts = [];
        $start = 0;
        $len = strlen($string);

        while ($start < $len) {
            $commaPos = strpos($string, ',', $start);
            $singlePos = strpos($string, '\'', $start);
            $doublePos = strpos($string, '"', $start);

            // Find the smallest position that is not false
            $minPos = false;
            if ($commaPos !== false) {
                $minPos = $commaPos;
            }
            if ($singlePos !== false && ($minPos === false || $singlePos < $minPos)) {
                $minPos = $singlePos;
            }
            if ($doublePos !== false && ($minPos === false || $doublePos < $minPos)) {
                $minPos = $doublePos;
            }

            if ($minPos === false) {
                // No more commas, single quotes, or double quotes found
                $part = substr($string, $start);
                $parts[] = trim($part);
                break;
            } else {
                // Add the current part to the parts array
                $part = substr($string, $start, ($minPos - $start));
                $parts[] = trim($part);

                // Update the start position to the next character after the comma, single quote, or double quote
                if ($string[$minPos] === ',') {
                    $start = ($minPos + 1);
                } else {
                    $quoteChar = $string[$minPos];
                    $endPos = strpos($string, $quoteChar, ($minPos + 1));
                    if ($endPos === false) {
                        $part = substr($string, ($minPos + 1));
                        $parts[] = trim($part);
                        break;
                    } else {
                        $part = substr($string, ($minPos + 1), ($endPos - $minPos - 1));
                        $parts[] = trim($part);
                        $start = ($endPos + 1);
                    }
                }
            }//end if
        }//end while

        return array_filter($parts, function ($value) {
            return $value !== '';
        });
    }

    /**
     * Filter a field with a specific Builder object
     *
     * @param Builder $builder The builder object to append.
     * @param string  $field   The field name.
     * @param mixed   $value   The value or array of values to filter.
     * @param string  $boolean The comparision boolean (AND or OR).
     * @return void
     */
    private function filterFieldWithBuilder(Builder $builder, string $field, mixed $value, string $boolean = 'AND')
    {
        $values = [];

        // Split by comma, but respect quotation marks
        if (is_string($value) === true) {
            $values = $this->splitString($value);
        } elseif (is_array($value) === true) {
            $values = $value;
        } else {
            throw new \InvalidArgumentException('Expected string or array, got ' . gettype($value));
        }

        // Add each AND check to the query
        $builder->where(function ($query) use ($field, $values) {
            foreach ($values as $value) {
                $value = trim($value);
                $prefix = '';

                // Check if value has a prefix and remove it if it's a number
                if (preg_match('/^(!?=|[<>]=?|<>|!)([^=!<>].*)*$/', $value, $matches) > 0) {
                    $prefix = $matches[1];
                    $value = ($matches[2] ?? '');
                }

                // Apply the prefix to the query if the value is a number
                switch ($prefix) {
                    case '=':
                        $query->orWhere($field, '=', $value);
                        break;
                    case '!':
                        $query->orWhere($field, 'NOT LIKE', "%$value%");
                        break;
                    case '>':
                        $query->orWhere($field, '>', $value);
                        break;
                    case '<':
                        $query->orWhere($field, '<', $value);
                        break;
                    case '>=':
                        $query->orWhere($field, '>=', $value);
                        break;
                    case '<=':
                        $query->orWhere($field, '<=', $value);
                        break;
                    case '!=':
                        $query->orWhere($field, '!=', $value);
                        break;
                    case '<>':
                        $seperatorPos = strpos($value, '|');
                        if ($seperatorPos !== false) {
                            $query->orWhereBetween($field, [substr($value, 0, $seperatorPos), substr($value, ($seperatorPos + 1))]);
                        } else {
                            $query->orWhere($field, '!=', $value);
                        }
                        break;
                    default:
                        $query->orWhere($field, 'LIKE', "%$value%");
                        break;
                }//end switch
            }//end foreach
        }, null, null, $boolean);
    }

    /**
     * Run the conductor on a Request to generate a collection and total.
     *
     * @param Request $request The request data.
     * @return array The processed and transformed collection | the total rows found.
     */
    final public static function request(Request $request)
    {
        $conductor_class = get_called_class();
        $conductor = new $conductor_class();

        $total = 0;

        try {
            $conductor->query = $conductor->class::query();
        } catch (\Throwable $e) {
            throw new \Exception('Failed to create query builder instance for ' . $conductor->class . '.', 0, $e);
        }

        // Filter request
        $fields = $conductor->fields(new $conductor->class());
        if (is_array($fields) === false) {
            $fields = [];
        }

        $params = $request->all();
        $filterFields = (array_intersect_key($params, array_flip($fields)) + $conductor->defaultFilters);
        $conductor->filter($filterFields);
        if ($request->has('filter') === true) {
            $conductor->filterRaw($request->input('filter', ''), $fields);
        }

        // After Scope query
        $conductor->query->where(function ($query) use ($conductor) {
            $conductor->scope($query);
        });

        // Sort request
        $sort = $request->input('sort', $conductor->sort);
        if (strlen($sort) === 0) {
            if (strlen($conductor->sort) > 0) {
                $conductor->sort($conductor->sort);
            }
        } else {
            $conductor->sort($sort);
        }

        // Get total
        $total = $conductor->count();

        // Paginate
        $conductor->paginate($request->input('page', 1), $request->input('limit', -1), $request->input('offset', 0));

        // Limit fields
        $limitFields = array_map(function ($field) {
            if (strpos($field, '.') !== false) {
                return substr($field, 0, strpos($field, '.'));
            }
            return $field;
        }, explode(',', $request->input('fields')));
        if ($limitFields === null) {
            $limitFields = $fields;
        } else {
            $limitFields = array_intersect($limitFields, $fields);
        }
        $conductor->limitFields($limitFields);
        $conductor->collection = $conductor->query->get();

        // Transform and Includes
        $includes = $conductor->includes;
        if (count($limitFields) > 0) {
            $includes = array_intersect($limitFields, $conductor->includes);
        }

        $conductor->collection = $conductor->collection->map(function ($model) use ($conductor, $includes, $limitFields) {
            $conductor->applyIncludes($model, $includes);

            if (count($limitFields) > 0) {
                $model->setAppends(array_intersect($model->getAppends(), $limitFields));
            }

            $model = $conductor->transformModel($model);

            return $model;
        });

        return [$conductor->collection, $total];
    }

    /**
     * Run the conductor on a collection with the data stored in a Request.
     *
     * @param Request    $request    The request data.
     * @param Collection $collection The collection.
     * @return array The processed and transformed model data.
     */
    final public static function collection(Request $request, Collection $collection)
    {
        $conductor_class = get_called_class();
        $conductor = new $conductor_class();

        $transformedCollection = collect();

        foreach ($collection as $item) {
            if ($conductor->viewable($item) === true) {
                $transformedCollection->push($conductor->transformModel($item));
            }
        }

        return $transformedCollection;
    }

    /**
     * Run the conductor on a Model with the data stored in a Request.
     *
     * @param Request    $request The request data.
     * @param Model|null $model   The model.
     * @return array The processed and transformed model data.
     */
    final public static function includeModel(Request $request, string $key, mixed $model)
    {
        $fields = [];

        if ($request !== null && $request->has('fields') === true) {
            $requestFields = $request->input('fields');
            if ($requestFields !== null) {
                $requestFields = explode(',', $requestFields);
                if (in_array($key, $requestFields) === false) {
                    foreach ($requestFields as $field) {
                        if (strpos($field, $key . '.') === 0) {
                            $fields[] = substr($field, (strlen($key) + 1));
                        }
                    }
                }
            }
        }

        return static::model($fields, $model);
    }

    /**
     * Run the conductor on a Model with the data stored in a Request.
     *
     * @param mixed      $fields The fields to show.
     * @param Model|null $model  The model.
     * @return array The processed and transformed model data.
     */
    final public static function model(mixed $fields, mixed $model)
    {
        if ($model === null) {
            return null;
        }

        $conductor_class = get_called_class();
        $conductor = new $conductor_class();

        $modelFields = $conductor->fields(new $conductor->class());

        // Limit fields
        $limitFields = $modelFields;
        if ($fields instanceof Request) {
            if ($fields !== null && $fields->has('fields') === true) {
                $requestFields = $fields->input('fields');
                if ($requestFields !== null) {
                    $limitFields = array_intersect(explode(',', $requestFields), $modelFields);
                }
            }
        } elseif (is_array($fields) && count($fields) > 0) {
            $limitFields = array_intersect($fields, $modelFields);
        }

        if (empty($limitFields) === false) {
            $modelAppends = $model->getAppends();

            foreach (array_diff($modelFields, $limitFields) as $attribute) {
                $key = array_search($attribute, $modelAppends);
                if ($key !== false) {
                    unset($modelAppends[$key]);
                } else {
                    unset($model[$attribute]);
                }
            }
            $model->setAppends($modelAppends);
        }

        // Includes
        $includes = array_intersect($limitFields, $conductor->includes);
        $conductor->applyIncludes($model, $includes);

        // Transform
        $model = $conductor->transformModel($model);

        return $model;
    }

    /**
     * Filter a single field in the conductor collection.
     *
     * @param string $field   The field name.
     * @param mixed  $value   The value or array of values to filter.
     * @param string $boolean The comparision boolean (AND or OR).
     * @return void
     */
    final public function filterField(string $field, mixed $value, string $boolean = 'AND')
    {
        $this->filterFieldWithBuilder($this->query, $field, $value, $boolean);
    }

    /**
     * Get or Set the conductor collection.
     *
     * @param Collection $collection If not null, use the passed collection.
     * @return Collection The current conductor collection.
     */
    // final public function collection(Collection $collection = null)
    // {
    //     if ($collection !== null) {
    //         $this->collection = $collection;
    //     }

    //     return $this->collection;
    // }


    /**
     * Return the current conductor collection count.
     *
     * @return integer The current collection count.
     */
    final public function count()
    {
        if ($this->query !== null) {
            return $this->query->count();
        }

        return 0;
    }

    /**
     * Sort the conductor collection.
     *
     * @param mixed $fields A field name or array of field names to sort. Supports a prefix of + or - to change direction.
     * @return void
     */
    final public function sort(mixed $fields = null)
    {
        if (is_string($fields) === true) {
            $fields = explode(',', $fields);
        } elseif ($fields === null) {
            $fields = $this->sort;
        }

        if (is_array($fields) === true) {
            foreach ($fields as $orderByField) {
                $direction = 'asc';
                $directionChar = substr($orderByField, 0, 1);

                if (in_array($directionChar, ['-', '+']) === true) {
                    $orderByField = substr($orderByField, 1);
                    if ($directionChar === '-') {
                        $direction = 'desc';
                    }
                }

                $this->query->orderBy(trim($orderByField), $direction);
            }
        } else {
            throw new \InvalidArgumentException('Expected string or array, got ' . gettype($fields));
        }
    }

    /**
     * Filter the conductor collection based on an array of field => value.
     *
     * @param array $filters An array of field => value to filter.
     * @return void
     */
    final public function filter(array $filters)
    {
        foreach ($filters as $param => $value) {
            $this->filterField($param, $value);
        }
    }

    /**
     * Paginate the conductor collection.
     *
     * @param integer $page   The current page to return.
     * @param integer $limit  The limit of items to include or use default.
     * @param integer $offset Offset the page count after this count of rows.
     * @return void
     */
    final public function paginate(int $page = 1, int $limit = -1, int $offset = 0)
    {
        // Limit
        if ($limit < 1) {
            $limit = $this->limit;
        } else {
            $limit = min($limit, $this->maxLimit);
        }
        $this->query->limit($limit);

        // Page
        if ($page < 1) {
            $page = 1;
        }

        // After
        if ($offset < 0) {
            $offset = 0;
        }

        $this->query->offset((($page - 1) * $limit) + $offset);
    }

    /**
     * Apply a list of includes to the model.
     *
     * @param Model $model    The model to append.
     * @param array $includes The list of includes to include.
     * @return void
     */
    final public function applyIncludes(Model $model, array $includes)
    {
        foreach ($includes as $include) {
            $includeMethodName = 'include' . Str::studly($include);
            if (method_exists($this, $includeMethodName) === true) {
                $attributeName = Str::snake($include);
                $attributeValue = $this->{$includeMethodName}($model);
                if ($attributeValue !== null) {
                    $model->$attributeName = $this->{$includeMethodName}($model);
                }
            }
        }
    }

    /**
     * Limit the returned fields in the conductor collection.
     *
     * @param array $fields An array of field names.
     * @return void
     */
    final public function limitFields(array $fields)
    {
        if (empty($fields) !== true) {
            $this->query->select(array_diff($fields, $this->includes));
        }
    }

    /**
     * Filter the conductor collection using raw data.
     *
     * @param string     $filterString The raw filter string to parse.
     * @param array|null $limitFields  The fields to ignore in the filter string.
     * @return void
     */
    final public function filterRaw(string $filterString, array|null $limitFields = null)
    {
        if (is_array($limitFields) === false || empty($limitFields) === true) {
            $limitFields = null;
        } else {
            $limitFields = array_map('strtolower', $limitFields);
        }
        $tokens = preg_split('/([()]|,OR,|,AND,|,)/', $filterString, -1, (PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE));
        $glued = [];
        $glueToken = '';
        foreach ($tokens as $item) {
            if ($glueToken === '') {
                $amount = preg_match_all('/(?<!\\\\)[\'"]/', $item, $matches, PREG_OFFSET_CAPTURE);
                if ($amount > 0) {
                    $glueToken = $matches[0][0][0];
                    if ($amount === 1) {
                        $item = substr($item, 0, $matches[0][1]) . substr($item, ($matches[0][1] + 1));
                        $item = str_replace("\\$glueToken", $glueToken, $item);
                        $glued[] = $item;
                    } else {
                        $lastPos = 0;
                        $newStr = '';
                        foreach ($matches[0] as $pos) {
                            $matchLen = strlen($glueToken);
                            $startPos = ($pos[1] - $lastPos);
                            $newStr .= substr($item, $lastPos, $startPos);
                            $lastPos = ($pos[1] + $matchLen);
                        }
                        $newStr .= substr($item, $lastPos);
                        $newStr = str_replace("\\$glueToken", $glueToken, $newStr);
                        $glued[] = $newStr;
                        $glueToken = '';
                    }
                } else {
                    $glued[] = $item;
                }//end if
            } else {
                // search for ending glue token
                if (preg_match('/(?<!\\\\)' . $glueToken . '/', $item, $matches, PREG_OFFSET_CAPTURE) === 1) {
                    $item = substr($item, 0, $matches[0][1]) . substr($item, ($matches[0][1] + 1));
                    $glueToken = '';
                }

                $item = str_replace("\\$glueToken", $glueToken, $item);

                $glued[(count($glued) - 1)] .= $item;
            }//end if
        }//end foreach
        $tokens = $glued;

        $parseTokens = function ($tokenList, $level, $index, $groupBoolean = null) use ($limitFields, &$parseTokens) {
            $tokenGroup = [];
            $firstToken = false;
            $tokenGroupBoolean = 'AND';

            if ($groupBoolean !== null) {
                $firstToken = true;
                $tokenGroupBoolean = $groupBoolean;
            }

            while ($index < count($tokenList)) {
                $token = $tokenList[$index];

                ++$index;
                if ($token === '(') {
                    // next group
                    $nextGroupBoolean = null;
                    if (count($tokenGroup) > 0 && strlen($tokenGroup[(count($tokenGroup) - 1)]['field']) === 0) {
                        $nextGroupBoolean = $tokenGroup[(count($tokenGroup) - 1)]['boolean'];
                        unset($tokenGroup[(count($tokenGroup) - 1)]);
                    }

                    $index = $parseTokens($tokenList, $level + 1, $index, $nextGroupBoolean);
                } elseif ($token === ')') {
                    // end group
                    break;
                } elseif (in_array(strtoupper($token), [',AND,', ',OR,']) === true) {
                    // update boolean
                    $boolean = trim(strtoupper($token), ',');

                    if ($firstToken === false && $level > 0) {
                        $tokenGroupBoolean = $boolean;
                    } else {
                        $firstToken = true;
                        $tokenGroup[] = [
                            'field' => '',
                            'value' => '',
                            'boolean' => $boolean
                        ];
                    }
                } elseif (strpos($token, ':') !== false) {
                    // set tokenGroup
                    $firstToken = true;
                    $field = substr($token, 0, strpos($token, ':'));
                    $value = substr($token, (strpos($token, ':') + 1));
                    $boolean = 'AND';

                    if (count($tokenGroup) > 0 && strlen($tokenGroup[(count($tokenGroup) - 1)]['field']) === 0) {
                        $tokenGroup[(count($tokenGroup) - 1)]['field'] = $field;
                        $tokenGroup[(count($tokenGroup) - 1)]['value'] = $value;
                        $boolean = $tokenGroup[(count($tokenGroup) - 1)]['boolean'];
                    } else {
                        $tokenGroup[] = [
                            'field' => $field,
                            'value' => $value,
                            'boolean' => 'AND'
                        ];
                    }

                    if ($limitFields === null || in_array(strtolower($field), $limitFields) !== true) {
                        unset($tokenGroup[(count($tokenGroup) - 1)]);
                    }

                    if ($level === 0) {
                        $this->filterFieldWithBuilder($this->query, $field, $value, $boolean);
                    }
                }//end if
            }//end while

            if ($level > 0) {
                if ($tokenGroupBoolean === 'OR') {
                    $this->query->orWhere(function ($query) use ($tokenGroup) {
                        foreach ($tokenGroup as $tokenItem) {
                            if (strlen($tokenItem['field']) > 0) {
                                $this->filterFieldWithBuilder($query, $tokenItem['field'], $tokenItem['value'], $tokenItem['boolean']);
                            }
                        }
                    });
                } else {
                    $this->query->where(function ($query) use ($tokenGroup) {
                        foreach ($tokenGroup as $tokenItem) {
                            if (strlen($tokenItem['field']) > 0) {
                                $this->filterFieldWithBuilder($query, $tokenItem['field'], $tokenItem['value'], $tokenItem['boolean']);
                            }
                        }
                    });
                }
            }//end if

            return $index;
        };

        $parseTokens($tokens, 0, 0);
    }

    /**
     * Run a scope query on the collection before anything else.
     *
     * @param Builder $builder The builder in use.
     * @return void
     */
    public function scope(Builder $builder)
    {
    }

    /**
     * Return an array of model fields visible to the current user.
     *
     * @param Model   $model    The model in question.
     * @param boolean $includes Include the includes in the result.
     * @return array The array of field names.
     */
    public function fields(Model $model)
    {
        $visibleFields = $model->getVisible();
        if (empty($visibleFields) === true) {
            $visibleFields = $model->getConnection()
                ->getSchemaBuilder()
                ->getColumnListing($model->getTable());
        }

        $appends = $model->getAppends();
        if (is_array($appends) === true) {
            $visibleFields = array_merge($visibleFields, $appends);
        }

        if (is_array($this->includes) === true) {
            $visibleFields = array_merge($visibleFields, $this->includes);
        }

        return $visibleFields;
    }

    /**
     * Transform the passed Model to an array
     *
     * @param Model $model The model to transform.
     * @return array The transformed model.
     */
    protected function transformModel(Model $model)
    {
        $result = $this->transform($model);
        foreach ($result as $key => $value) {
            $transformFunction = 'transform' . Str::studly($key);
            if (method_exists($this, $transformFunction)) {
                $result[$key] = $this->$transformFunction($value);
            }
        }

        $result = $this->transformFinal($result);
        return $result;
    }

    /**
     * Transform the passed Model to an array
     *
     * @param Model $model The model to transform.
     * @return array The transformed model.
     */
    public function transform(Model $model)
    {
        $result = $model->toArray();

        $fields = $this->fields($model);

        if (is_array($fields) === true) {
            $result = array_intersect_key($result, array_flip($fields));
        }

        return $result;
    }

    /**
     * Final Transform of the model array
     *
     * @param array $data The model array to transform.
     * @return array The transformed model.
     */
    public function transformFinal(array $data)
    {
        return $data;
    }

    /**
     * Is the passed model viewable by the current user?
     *
     * @param Model $model The model in question.
     * @return boolean Is the model viewable.
     */
    public static function viewable(Model $model)
    {
        return true;
    }

    /**
     * Is the model creatable by the current user?
     *
     * @return boolean Is the model creatable.
     */
    public static function creatable()
    {
        return true;
    }

    /**
     * Is the passed model updateable by the current user?
     *
     * @param Model $model The model in question.
     * @return boolean Is the model updateable.
     */
    public static function updatable(Model $model)
    {
        return true;
    }

    /**
     * Is the passed model destroyable by the current user?
     *
     * @param Model $model The model in question.
     * @return boolean Is the model destroyable.
     */
    public static function destroyable(Model $model)
    {
        return true;
    }
}
