<?php

/**
 * League.Csv (https://csv.thephpleague.com)
 *
 * (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace GeminiLabs\League\Csv\Query;

use ArrayAccess;
use ReflectionException;
use ReflectionObject;
use TypeError;

use function array_is_list;
use function array_key_exists;
use function array_map;
use function array_values;
use function count;
use function explode;
use function implode;
use function is_array;
use function is_int;
use function is_object;
use function lcfirst;
use function str_replace;

final class Row
{
    public static function from(mixed $value): Row
    {
        return new self(match (true) {
            is_object($value),
            is_array($value) => $value,
            default => throw new TypeError('The value must be an array or an object; received '.gettype($value).'.'),
        });
    }

    private function __construct(private readonly array|object $row)
    {
    }

    /**
     * Tries to retrieve a single value from a record.
     *
     * @throws ReflectionException
     * @throws QueryException If the value can not be retrieved
     * @see Row::select()
     *
     */
    public function value(string|int $key): mixed
    {
        return $this->select($key)[$key];
    }

    /**
     * Tries to retrieve multiple values from a record.
     *
     * If the value is an array and the key is an integer the content will be retrieved
     * from the array_values array form. Negative offset are supported.
     * If the value is an object, the key MUST be a string.
     *
     * @throws ReflectionException
     * @throws QueryException If the value can not be retrieved
     *
     * @return non-empty-array<array-key, mixed>
     */
    public function select(string|int ...$key): array
    {
        return match (true) {
            is_object($this->row) => self::getObjectPropertyValue($this->row, ...$key),
            default => self::getArrayEntry($this->row, ...$key),
        };
    }

    /**
     * @throws QueryException
     *
     * @return non-empty-array<array-key, mixed>
     */
    private function getArrayEntry(array $row, string|int ...$keys): array
    {
        $res = [];
        $arrValues = array_values($row);
        foreach ($keys as $key) {
            if (array_key_exists($key, $res)) {
                continue;
            }
            $offset = $key;
            if (is_int($offset)) {
                if (!array_is_list($row)) {
                    $row = $arrValues;
                }

                if ($offset < 0) {
                    $offset += count($row);
                }
            }

            $res[$key] = array_key_exists($offset, $row) ? $row[$offset] : throw QueryException::dueToUnknownColumn($key, $row);
        }

        return [] !== $res ? $res : throw QueryException::dueToMissingColumn();
    }

    /**
     * @throws ReflectionException
     * @throws QueryException
     *
     * @return non-empty-array<array-key, mixed>
     */
    private static function getObjectPropertyValue(object $row, string|int ...$keys): array
    {
        $res = [];
        $object = new ReflectionObject($row);
        foreach ($keys as $key) {
            if (array_key_exists($key, $res)) {
                continue;
            }

            !is_int($key) || throw QueryException::dueToUnknownColumn($key, $row);

            if ($object->hasProperty($key) && $object->getProperty($key)->isPublic()) {
                $res[$key] = $object->getProperty($key)->getValue($row);
                continue;
            }

            $methodNameList = [$key];
            if (($camelCasedKey = self::camelCase($key)) !== $key) {
                $methodNameList[] = $camelCasedKey;
            }
            $methodNameList[] = self::camelCase($key, 'get');
            foreach ($methodNameList as $methodName) {
                if ($object->hasMethod($methodName)
                    && $object->getMethod($methodName)->isPublic()
                    && 1 > $object->getMethod($methodName)->getNumberOfRequiredParameters()
                ) {
                    $res[$key] = $object->getMethod($methodName)->invoke($row);
                    continue 2;
                }
            }

            if (method_exists($row, '__call')) {
                $res[$key] = $object->getMethod('__call')->invoke($row, $methodNameList[1]);
                continue;
            }

            if ($row instanceof ArrayAccess && $row->offsetExists($key)) {
                $res[$key] =  $row->offsetGet($key);
                continue;
            }

            throw QueryException::dueToUnknownColumn($key, $row);
        }

        return [] !== $res ? $res : throw QueryException::dueToMissingColumn();
    }

    private static function camelCase(string $value, string $prefix = ''): string
    {
        if ('' !== $prefix) {
            $prefix .= '_';
        }

        return lcfirst(implode('', array_map(
            ucfirst(...),
            explode(' ', str_replace(['-', '_'], ' ', $prefix.$value))
        )));
    }
}
