<?php

declare(strict_types=1);

namespace App\Core;

use PDO;

/**
 * Validator
 *
 * A small, dependency-free rule-based validator modeled on the
 * Laravel-style `"field" => "required|email|max:191"` syntax because
 * it's the convention most PHP developers already know. Every future
 * module (leads, customers, appointments...) defines its own rule set
 * and calls Validator::make() — nothing about a specific business
 * entity lives in this file.
 *
 * Usage:
 *   $v = Validator::make($request->all(), [
 *       'email' => 'required|email|max:191|unique:users,email',
 *       'first_name' => 'required|string|max:100',
 *   ]);
 *   if ($v->fails()) {
 *       return Response::json(['success' => false, 'errors' => $v->errors()], 422);
 *   }
 *   $clean = $v->validated();
 */
final class Validator
{
    private array $data;
    private array $rules;
    private array $errors = [];
    private array $customMessages;

    private function __construct(array $data, array $rules, array $messages)
    {
        $this->data = $data;
        $this->rules = $rules;
        $this->customMessages = $messages;
        $this->run();
    }

    public static function make(array $data, array $rules, array $messages = []): self
    {
        return new self($data, $rules, $messages);
    }

    public function fails(): bool
    {
        return $this->errors !== [];
    }

    public function passes(): bool
    {
        return !$this->fails();
    }

    public function errors(): array
    {
        return $this->errors;
    }

    /** Only the fields that had rules defined, so mass-assignment never picks up unexpected columns. Empty-string values are normalized to null — an unfilled optional field (e.g. a blank "Estimated Value" input) submits '' over HTTP, and passing that raw into a numeric/date database column causes a strict-mode SQL error rather than being treated as "no value". Any field reaching this method with an empty value is necessarily non-required (a required field with '' would already have failed validation before validated() is ever called), so this normalization never masks a real validation gap. */
    public function validated(): array
    {
        $out = [];
        foreach (array_keys($this->rules) as $field) {
            if (array_key_exists($field, $this->data)) {
                $value = $this->data[$field];
                $out[$field] = $value === '' ? null : $value;
            }
        }

        return $out;
    }

    private function run(): void
    {
        foreach ($this->rules as $field => $ruleString) {
            $rules = explode('|', $ruleString);
            $value = $this->data[$field] ?? null;
            $isPresent = array_key_exists($field, $this->data) && $this->data[$field] !== '' && $this->data[$field] !== null;
            $isRequired = in_array('required', $rules, true);

            if (!$isPresent && !$isRequired) {
                continue; // optional + absent: nothing to validate
            }

            foreach ($rules as $rule) {
                [$name, $param] = array_pad(explode(':', $rule, 2), 2, null);
                $isNumericField = in_array('numeric', $rules, true) || in_array('integer', $rules, true);
                $this->applyRule($field, $value, $name, $param, $isPresent, $isNumericField);
            }
        }
    }

    private function applyRule(string $field, mixed $value, string $rule, ?string $param, bool $isPresent, bool $isNumericField = false): void
    {
        $ok = match ($rule) {
            'required'  => $isPresent,
            'string'    => !$isPresent || is_string($value),
            'numeric'   => !$isPresent || is_numeric($value),
            'integer'   => !$isPresent || filter_var($value, FILTER_VALIDATE_INT) !== false,
            'boolean'   => !$isPresent || in_array($value, [true, false, 0, 1, '0', '1'], true),
            'email'     => !$isPresent || filter_var($value, FILTER_VALIDATE_EMAIL) !== false,
            'url'       => !$isPresent || filter_var($value, FILTER_VALIDATE_URL) !== false,
            'date'      => !$isPresent || self::isValidDate((string) $value),
            'array'     => !$isPresent || is_array($value),
            'min'       => !$isPresent || self::compareLength($value, (float) $param, '>=', $isNumericField),
            'max'       => !$isPresent || self::compareLength($value, (float) $param, '<=', $isNumericField),
            'between'   => !$isPresent || self::between($value, $param, $isNumericField),
            'in'        => !$isPresent || in_array((string) $value, explode(',', (string) $param), true),
            'regex'     => !$isPresent || (bool) preg_match((string) $param, (string) $value),
            'confirmed' => $value === ($this->data[$field . '_confirmation'] ?? null),
            'unique'    => !$isPresent || $this->isUnique($value, $param),
            'exists'    => !$isPresent || $this->exists($value, $param),
            default     => true,
        };

        if (!$ok) {
            $this->addError($field, $rule, $param);
        }
    }

    private function addError(string $field, string $rule, ?string $param): void
    {
        $key = "{$field}.{$rule}";
        $message = $this->customMessages[$key]
            ?? $this->customMessages[$field]
            ?? self::defaultMessage($field, $rule, $param);

        $this->errors[$field][] = $message;
    }

    private static function defaultMessage(string $field, string $rule, ?string $param): string
    {
        $label = ucfirst(str_replace('_', ' ', $field));

        return match ($rule) {
            'required'  => "{$label} is required.",
            'email'     => "{$label} must be a valid email address.",
            'url'       => "{$label} must be a valid URL.",
            'numeric'   => "{$label} must be a number.",
            'integer'   => "{$label} must be a whole number.",
            'date'      => "{$label} must be a valid date.",
            'min'       => "{$label} must be at least {$param}.",
            'max'       => "{$label} may not be greater than {$param}.",
            'between'   => "{$label} must be between {$param}.",
            'in'        => "{$label} must be one of: {$param}.",
            'confirmed' => "{$label} confirmation does not match.",
            'unique'    => "{$label} is already taken.",
            'exists'    => "{$label} does not reference a valid record.",
            default     => "{$label} is invalid.",
        };
    }

    private static function isValidDate(string $value): bool
    {
        $formats = ['Y-m-d', 'Y-m-d H:i:s', 'Y-m-d\TH:i'];
        foreach ($formats as $format) {
            $d = \DateTime::createFromFormat($format, $value);
            if ($d && $d->format($format) === $value) {
                return true;
            }
        }

        return strtotime($value) !== false;
    }

    private static function compareLength(mixed $value, float $limit, string $operator, bool $isNumericField = false): bool
    {
        $length = $isNumericField && is_numeric($value) ? (float) $value : mb_strlen((string) $value);

        return $operator === '>=' ? $length >= $limit : $length <= $limit;
    }

    private static function between(mixed $value, ?string $param, bool $isNumericField = false): bool
    {
        $bounds = array_map('trim', explode(',', (string) $param));
        if (count($bounds) !== 2) {
            return true;
        }

        $length = $isNumericField && is_numeric($value) ? (float) $value : mb_strlen((string) $value);

        return $length >= (float) $bounds[0] && $length <= (float) $bounds[1];
    }

    /** unique:table,column[,ignore_id] */
    private function isUnique(mixed $value, ?string $param): bool
    {
        $parts = explode(',', (string) $param);
        $table = $parts[0] ?? null;
        $column = $parts[1] ?? 'id';
        $ignoreId = $parts[2] ?? null;

        if ($table === null) {
            return true;
        }

        $sql = "SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = :value";
        $bindings = ['value' => $value];

        if ($this->tableHasSoftDeletes($table)) {
            $sql .= ' AND deleted_at IS NULL';
        }

        if ($ignoreId !== null) {
            $sql .= ' AND id != :ignore_id';
            $bindings['ignore_id'] = $ignoreId;
        }

        $stmt = Database::connection()->prepare($sql);
        $stmt->execute($bindings);

        return (int) $stmt->fetchColumn() === 0;
    }

    /** exists:table,column */
    private function exists(mixed $value, ?string $param): bool
    {
        $parts = explode(',', (string) $param);
        $table = $parts[0] ?? null;
        $column = $parts[1] ?? 'id';

        if ($table === null) {
            return true;
        }

        $stmt = Database::connection()->prepare("SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = :value");
        $stmt->execute(['value' => $value]);

        return (int) $stmt->fetchColumn() > 0;
    }

    private function tableHasSoftDeletes(string $table): bool
    {
        static $cache = [];

        if (isset($cache[$table])) {
            return $cache[$table];
        }

        $stmt = Database::connection()->prepare(
            "SELECT COUNT(*) FROM information_schema.COLUMNS
             WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table AND COLUMN_NAME = 'deleted_at'"
        );
        $stmt->execute(['table' => $table]);

        return $cache[$table] = (int) $stmt->fetchColumn() > 0;
    }
}
