<?php

declare(strict_types=1);

namespace App\Support\Diff;

use BackedEnum;
use Illuminate\Support\Str;
use Stringable;

final class DiffPresenter
{
    /**
     * @param  array<string, mixed>  $baseline
     * @param  array<string, mixed>  $current
     * @param  array<int, string>  $changedKeys
     * @param  array<string, string>  $labels
     * @param  array<string, array<string, mixed>>  $meta
     */
    public function present(
        array $baseline,
        array $current,
        array $changedKeys = [],
        array $labels = [],
        array $meta = [],
    ): DiffPresentation {
        $keys = [];

        foreach ([array_keys($baseline), array_keys($current), $changedKeys] as $sourceKeys) {
            foreach ($sourceKeys as $key) {
                if (! is_string($key) || trim($key) === '') {
                    continue;
                }

                $keys[$key] = true;
            }
        }

        if ($keys === []) {
            return DiffPresentation::empty();
        }

        $changedLookup = [];

        foreach ($changedKeys as $changedKey) {
            if (! is_string($changedKey) || trim($changedKey) === '') {
                continue;
            }

            $changedLookup[$changedKey] = true;
        }

        $rows = [];

        foreach (array_keys($keys) as $key) {
            $hasOldValue = array_key_exists($key, $baseline);
            $hasNewValue = array_key_exists($key, $current);

            if (! $hasOldValue && ! $hasNewValue) {
                continue;
            }

            $oldValue = $baseline[$key] ?? null;
            $newValue = $current[$key] ?? null;
            $isListLike = $this->isListRowCandidate($hasOldValue, $oldValue, $hasNewValue, $newValue);
            [$addedItems, $removedItems, $unchangedItems] = $isListLike
                ? $this->prepareListFragments(
                    $hasOldValue && is_array($oldValue) ? $oldValue : [],
                    $hasNewValue && is_array($newValue) ? $newValue : [],
                )
                : [[], [], []];

            $rows[] = new DiffRow(
                key: $key,
                label: $this->labelForKey($key, $labels[$key] ?? null),
                status: $this->statusFor(
                    key: $key,
                    hasOldValue: $hasOldValue,
                    hasNewValue: $hasNewValue,
                    oldValue: $oldValue,
                    newValue: $newValue,
                    changedLookup: $changedLookup,
                ),
                oldValue: $oldValue,
                newValue: $newValue,
                isListLike: $isListLike,
                addedItems: $addedItems,
                removedItems: $removedItems,
                unchangedItems: $unchangedItems,
                meta: is_array($meta[$key] ?? null) ? $meta[$key] : [],
            );
        }

        usort($rows, function (DiffRow $left, DiffRow $right): int {
            return [mb_strtolower($left->label), $left->key] <=> [mb_strtolower($right->label), $right->key];
        });

        return new DiffPresentation(
            summary: DiffSummary::fromRows($rows),
            rows: $rows,
        );
    }

    /**
     * @param  array<string, bool>  $changedLookup
     */
    private function statusFor(
        string $key,
        bool $hasOldValue,
        bool $hasNewValue,
        mixed $oldValue,
        mixed $newValue,
        array $changedLookup,
    ): DiffRowStatus {
        if (! $hasOldValue) {
            return DiffRowStatus::Added;
        }

        if (! $hasNewValue) {
            return DiffRowStatus::Removed;
        }

        if (array_key_exists($key, $changedLookup) || $oldValue !== $newValue) {
            return DiffRowStatus::Changed;
        }

        return DiffRowStatus::Unchanged;
    }

    private function labelForKey(string $key, mixed $label): string
    {
        if (is_string($label) && trim($label) !== '') {
            return trim($label);
        }

        $segments = preg_split('/\s*>\s*|\./', $key) ?: [];
        $segments = array_values(array_filter(array_map(function (string $segment): string {
            return Str::of($segment)
                ->replace(['_', '-'], ' ')
                ->headline()
                ->trim()
                ->toString();
        }, $segments)));

        if ($segments !== []) {
            return implode(' > ', $segments);
        }

        return Str::of($key)->headline()->toString();
    }

    private function isListRowCandidate(
        bool $hasOldValue,
        mixed $oldValue,
        bool $hasNewValue,
        mixed $newValue,
    ): bool {
        if ($hasOldValue && $hasNewValue) {
            return $this->isInlineList($oldValue) && $this->isInlineList($newValue);
        }

        if ($hasOldValue) {
            return $this->isInlineList($oldValue);
        }

        return $this->isInlineList($newValue);
    }

    private function isInlineList(mixed $value): bool
    {
        if (! is_array($value) || ! array_is_list($value)) {
            return false;
        }

        foreach ($value as $item) {
            if (! $this->isInlineListItem($item)) {
                return false;
            }
        }

        return true;
    }

    private function isInlineListItem(mixed $item): bool
    {
        return $item === null
            || is_scalar($item)
            || $item instanceof BackedEnum
            || $item instanceof Stringable;
    }

    /**
     * @param  array<int, mixed>  $baseline
     * @param  array<int, mixed>  $current
     * @return array{0: array<int, mixed>, 1: array<int, mixed>, 2: array<int, mixed>}
     */
    private function prepareListFragments(array $baseline, array $current): array
    {
        $remainingBaseline = [];

        foreach ($baseline as $item) {
            $signature = $this->listItemSignature($item);
            $remainingBaseline[$signature] = ($remainingBaseline[$signature] ?? 0) + 1;
        }

        $addedItems = [];
        $unchangedItems = [];

        foreach ($current as $item) {
            $signature = $this->listItemSignature($item);

            if (($remainingBaseline[$signature] ?? 0) > 0) {
                $unchangedItems[] = $item;
                $remainingBaseline[$signature]--;

                continue;
            }

            $addedItems[] = $item;
        }

        $removedItems = [];

        foreach ($baseline as $item) {
            $signature = $this->listItemSignature($item);

            if (($remainingBaseline[$signature] ?? 0) <= 0) {
                continue;
            }

            $removedItems[] = $item;
            $remainingBaseline[$signature]--;
        }

        return [$addedItems, $removedItems, $unchangedItems];
    }

    private function listItemSignature(mixed $item): string
    {
        return serialize($this->normalizeListItem($item));
    }

    private function normalizeListItem(mixed $item): mixed
    {
        if ($item instanceof BackedEnum) {
            return $item->value;
        }

        if ($item instanceof Stringable) {
            return (string) $item;
        }

        return $item;
    }
}
