<?php

namespace App\Data;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use JsonSerializable;
use ReflectionClass;
use ReflectionNamedType;
use Stringable;

abstract class BaseData implements Arrayable, JsonSerializable, Stringable
{
    /**
     * Map remote keys -> local property names (override in child when needed).
     * e.g. ['date_created_gmt' => 'dateCreatedGmt']
     * @return array<string,string>
     */
    protected static function map(): array
    {
        return [];
    }

    /**
     * Per-property casting hints (override in child).
     * Supported: int, float, bool, string, datetime, array, mixed, collection:<class>, object:<class>
     * @return array<string,string>
     */
    protected static function casts(): array
    {
        return [];
    }

    /**
     * Laravel validation rules for input payload (override in child if desired).
     * @return array<string,mixed>
     */
    protected static function rules(): array
    {
        return [];
    }

    /**
     * Build instance from array/Request/JSON.
     */
    public static function from(array|Request|string $source): static
    {
        $data = match (true) {
            $source instanceof Request => $source->all(),
            is_string($source)  => (array) json_decode(json: $source, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR),
            default                    => $source,
        };

        // Apply validation if provided
        $rules = static::rules();
        if (!empty($rules)) {
            $validator = Validator::make($data, $rules)->validate();
        }

        // Normalize keys: snake_case → camelCase, then apply explicit map()
        $data = static::normalizeKeys($data);
        $data = static::applyMap($data, static::map());

        // Hydrate by reflection using child’s declared properties
        $ref = new ReflectionClass(static::class);
        $props = collect($ref->getProperties())
            ->filter(fn($p) => $p->isPublic()) // readonly recommended in child
            ->map(fn($p) => $p->getName())
            ->values();

        $casts = static::casts();
        $payload = [];

        foreach ($props as $prop) {
            $value = Arr::get($data, $prop);

            $hint = $casts[$prop] ?? static::inferTypeHint($ref, $prop);
            $payload[$prop] = static::castValue($value, $hint);
        }

        // Use named args to call child's promoted constructor if any; fall back to new + property assign
        if ($ref->getConstructor()?->getParameters()) {
            // Constructor property promotion scenario
            return new static(...$payload);
        }

        // No constructor: assign public properties directly
        $instance = new static();
        foreach ($payload as $k => $v) {
            $instance->{$k} = $v;
        }
        return $instance;
    }

    /**
     * Build a collection of data objects from list of arrays.
     * @param iterable<array|Request|string> $items
     */
    public static function collection(iterable $items): Collection
    {
        $out = [];
        foreach ($items as $item) {
            $out[] = static::from($item);
        }
        return collect($out);
    }

    /**
     * Convert to array (snake_case keys by default for APIs).
     */
    public function toArray(): array
    {
        $ref = new ReflectionClass($this);
        $out = [];
        foreach ($ref->getProperties() as $prop) {
            if (!$prop->isPublic()) continue;
            $name = $prop->getName();
            $val  = $this->{$name} ?? null;
            $out[$name] = static::toPrimitive($val);
        }
        // camelCase → snake_case for output
        return static::camelToSnakeKeys($out);
    }

    public function jsonSerialize(): mixed
    {
        return $this->toArray();
    }

    public function __toString(): string
    {
        return json_encode($this, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    /* ----------------------- Helpers ----------------------- */

    protected static function normalizeKeys(array $data): array
    {
        $out = [];
        foreach ($data as $k => $v) {
            $key = is_string($k) ? static::snakeToCamel($k) : $k;
            $out[$key] = is_array($v) ? static::normalizeKeys($v) : $v;
        }
        return $out;
    }

    protected static function applyMap(array $data, array $map): array
    {
        foreach ($map as $from => $to) {
            $from = static::snakeToCamel($from);
            if (array_key_exists($from, $data)) {
                $data[$to] = $data[$from];
                unset($data[$from]);
            }
        }
        return $data;
    }

    protected static function snakeToCamel(string $key): string
    {
        return preg_replace_callback('/_([a-z])/', fn($m) => strtoupper($m[1]), strtolower($key));
    }

    protected static function camelToSnakeKeys(array $data): array
    {
        $out = [];
        foreach ($data as $k => $v) {
            $snake = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $k));
            $out[$snake] = is_array($v) ? static::camelToSnakeKeys($v) : $v;
        }
        return $out;
    }

    protected static function inferTypeHint(ReflectionClass $ref, string $prop): ?string
    {
        if (!$ref->hasProperty($prop)) return null;
        $type = $ref->getProperty($prop)->getType();
        if ($type instanceof ReflectionNamedType) {
            return $type->getName(); // e.g. int, string, \DateTimeInterface, array, mixed
        }
        return null;
    }

    protected static function castValue(mixed $value, ?string $hint): mixed
    {
        if ($value === null || $hint === null) return $value;

        // Allow nullable like "?int"
        $nullable = str_starts_with($hint, '?');
        $base = ltrim($hint, '?');

        if ($value === null && $nullable) return null;

        switch ($base) {
            case 'int':
                return is_numeric($value) ? (int) $value : null;
            case 'float':
                return is_numeric($value) ? (float) $value : null;
            case 'bool':
                return (bool) $value;
            case 'string':
                return $value === null ? null : (string) $value;
            case 'array':
                return is_array($value) ? $value : (array) $value;
            case 'mixed':
                return $value;

            case 'datetime':
            case Carbon::class:
            case \DateTimeInterface::class:
                if ($value instanceof Carbon) return $value;
                if (empty($value)) return null;
                return Carbon::parse($value);

            default:
                // collection:<Class>
                if (str_starts_with($base, 'collection:')) {
                    $itemClass = substr($base, strlen('collection:'));
                    $items = is_array($value) ? $value : [];
                    return collect($items)->map(fn($it) => class_exists($itemClass) ? $itemClass::from($it) : $it);
                }
                // object:<Class>
                if (str_starts_with($base, 'object:')) {
                    $cls = substr($base, strlen('object:'));
                    return class_exists($cls) ? $cls::from($value ?? []) : $value;
                }
                return $value;
        }
    }

    protected static function toPrimitive(mixed $val): mixed
    {
        if ($val instanceof Carbon) {
            return $val->toIso8601String();
        }
        if ($val instanceof Arrayable) {
            return $val->toArray();
        }
        if ($val instanceof \UnitEnum) {
            return $val->value ?? $val->name;
        }
        if ($val instanceof \JsonSerializable) {
            return $val->jsonSerialize();
        }
        if ($val instanceof \DateTimeInterface) {
            return Carbon::instance($val)->toIso8601String();
        }
        if ($val instanceof Collection) {
            return $val->map(fn($i) => static::toPrimitive($i))->all();
        }
        if (is_array($val)) {
            return array_map(fn($i) => static::toPrimitive($i), $val);
        }
        return $val;
    }
}
