<?php

namespace Openguru\OpenCartFramework\Collection;

use ArrayAccess;
use InvalidArgumentException;
use ArrayIterator;
use IteratorAggregate;
use Countable;
use JsonSerializable;

/**
 * A generic collection for managing objects of a specific type.
 *
 * @template T
 * @implements IteratorAggregate<T>
 */
class Collection implements IteratorAggregate, Countable, ArrayAccess, JsonSerializable
{
    /** @var array<T> */
    protected array $items;

    /** @var string|null */
    protected ?string $expectedClass;

    /**
     * Constructor.
     *
     * @param array $items Initial items for the collection.
     * @param string|null $expectedClass The expected class type for the items.
     * @throws InvalidArgumentException
     */
    public function __construct(array $items = [], ?string $expectedClass = null)
    {
        $this->expectedClass = $expectedClass;

        foreach ($items as $item) {
            $this->validateItem($item);
        }

        $this->items = $items;
    }

    /**
     * Add an item to the collection.
     *
     * @param mixed $item
     * @return self
     * @throws InvalidArgumentException
     */
    public function add($item): self
    {
        $this->validateItem($item);
        $this->items[] = $item;
        return $this;
    }

    /**
     * Get all items.
     *
     * @return array<T>
     */
    public function all(): array
    {
        return $this->items;
    }

    /**
     * Filter items based on a callback.
     *
     * @param callable $callback
     * @return self
     */
    public function filter(callable $callback): self
    {
        return new self(array_values(array_filter($this->items, $callback)), $this->expectedClass);
    }

    /**
     * Apply a callback to each item and return a new collection.
     *
     * @param callable $callback
     * @return array
     */
    public function map(callable $callback): array
    {
        return array_map($callback, $this->items);
    }

    /**
     * Reduce the collection to a single value.
     *
     * @param callable $callback
     * @param mixed $initial
     * @return mixed
     */
    public function reduce(callable $callback, $initial)
    {
        return array_reduce($this->items, $callback, $initial);
    }

    /**
     * Find an item by a specific property and value.
     *
     * @param string $property
     * @param mixed $value
     * @return T|null
     */
    public function findByProperty(string $property, $value)
    {
        foreach ($this->items as $item) {
            if (is_object($item) && property_exists($item, $property) && $item->$property === $value) {
                return $item;
            }
        }

        return null;
    }

    /**
     * Check if an item exists with a specific property and value.
     *
     * @param string $property
     * @param mixed $value
     * @return bool
     */
    public function hasValue(string $property, $value): bool
    {
        return $this->findByProperty($property, $value) !== null;
    }

    /**
     * Validate an item type.
     *
     * @param mixed $item
     * @throws InvalidArgumentException
     */
    private function validateItem($item): void
    {
        if ($this->expectedClass !== null && !($item instanceof $this->expectedClass)) {
            throw new InvalidArgumentException(
                sprintf(
                    'Item must be an instance of %s, %s given.',
                    $this->expectedClass,
                    is_object($item) ? get_class($item) : gettype($item)
                )
            );
        }
    }

    /**
     * Get an iterator for the collection.
     */
    public function getIterator(): ArrayIterator
    {
        return new ArrayIterator($this->items);
    }

    /**
     * Count the number of items in the collection.
     *
     * @return int
     */
    public function count(): int
    {
        return count($this->items);
    }

    /**
     * Check if an offset exists.
     *
     * @param mixed $offset
     * @return bool
     */
    public function offsetExists($offset): bool
    {
        return isset($this->items[$offset]);
    }

    /**
     * Get an item at a specific offset.
     *
     * @param mixed $offset
     * @return mixed
     */
    public function offsetGet($offset)
    {
        return $this->items[$offset] ?? null;
    }

    /**
     * Set an item at a specific offset.
     *
     * @param mixed $offset
     * @param mixed $value
     */
    public function offsetSet($offset, $value): void
    {
        $this->validateItem($value);

        if ($offset === null) {
            $this->items[] = $value;
        } else {
            $this->items[$offset] = $value;
        }
    }

    /**
     * Unset an item at a specific offset.
     *
     * @param mixed $offset
     */
    public function offsetUnset($offset): void
    {
        unset($this->items[$offset]);
    }

    public function getValueByProperty(string $property, $propertyValue)
    {
        $item = $this->findByProperty($property, $propertyValue);

        return $item ? $item->getValue() : null;
    }

    /**
     * @return T|null
     */
    public function first()
    {
        return $this->items[0] ?? null;
    }

    public function getFirstItemValue(string $field, $default = null)
    {
        if ($this->first() === null) {
            return $default;
        }

        return $this->first()->{$field};
    }

    public function groupBy(callable $callback): array
    {
        $grouped = [];

        foreach ($this->items as $item) {
            $key = $callback($item);
            $grouped[$key][] = $item;
        }

        return $grouped;
    }

    public function keyByField(callable $callback): array
    {
        $items = [];

        foreach ($this->items as $item) {
            $key = $callback($item);
            $items[$key] = $item;
        }

        return $items;
    }

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

    public function pluck(string $field): array
    {
        $result = [];

        foreach ($this->items as $item) {
            if (is_object($item)) {
                $result[] = $item->$field;
            } else {
                $result[] = $item[$field];
            }
        }

        return $result;
    }

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

    public function append(array $items): Collection
    {
        return new Collection(array_merge($this->items, $items), $this->expectedClass);
    }
}
