<?php

declare(strict_types=1);

namespace App\Services\Recommendations;

use App\Models\UserInterestProfile;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;

final class UserInterestProfileService
{
    /**
     * @param array<string, mixed> $eventMeta
     */
    public function applyEvent(
        int $userId,
        string $eventType,
        int $artworkId,
        ?int $categoryId,
        CarbonInterface $occurredAt,
        string $eventId,
        string $algoVersion,
        array $eventMeta = []
    ): void {
        $profileVersion = (string) config('discovery.profile_version', 'profile-v1');
        $halfLifeHours = (float) config('discovery.decay.half_life_hours', 72);
        $weightMap = (array) config('discovery.weights', []);
        $eventWeight = (float) ($weightMap[$eventType] ?? 1.0);

        DB::transaction(function () use (
            $userId,
            $categoryId,
            $artworkId,
            $occurredAt,
            $eventId,
            $algoVersion,
            $profileVersion,
            $halfLifeHours,
            $eventWeight,
            $eventMeta
        ): void {
            $profile = UserInterestProfile::query()
                ->where('user_id', $userId)
                ->where('profile_version', $profileVersion)
                ->where('algo_version', $algoVersion)
                ->lockForUpdate()
                ->first();

            $rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : [];
            $lastEventAt = $profile?->last_event_at;

            if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) {
                $hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600);
                $rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours);
            }

            $interestKey = $categoryId !== null
                ? sprintf('category:%d', $categoryId)
                : sprintf('artwork:%d', $artworkId);

            $rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight;

            $rawScores = array_filter(
                $rawScores,
                static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001
            );

            $normalizedScores = $this->normalizeScores($rawScores);
            $totalWeight = array_sum($rawScores);

            $payload = [
                'user_id' => $userId,
                'profile_version' => $profileVersion,
                'algo_version' => $algoVersion,
                'raw_scores_json' => $rawScores,
                'normalized_scores_json' => $normalizedScores,
                'total_weight' => $totalWeight,
                'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1,
                'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt)
                    ? $occurredAt
                    : $lastEventAt,
                'half_life_hours' => $halfLifeHours,
                'updated_from_event_id' => $eventId,
                'updated_at' => now(),
            ];

            if ($profile === null) {
                $payload['created_at'] = now();
                UserInterestProfile::query()->create($payload);
                return;
            }

            $profile->fill($payload);
            $profile->save();
        }, 3);
    }

    /**
     * @param array<string, mixed> $scores
     * @return array<string, float>
     */
    public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array
    {
        if ($hoursElapsed <= 0 || $halfLifeHours <= 0) {
            return $this->castToFloatScores($scores);
        }

        $decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
        $output = [];

        foreach ($scores as $key => $score) {
            if (! is_numeric($score)) {
                continue;
            }

            $decayed = (float) $score * $decayFactor;
            if ($decayed > 0.000001) {
                $output[(string) $key] = $decayed;
            }
        }

        return $output;
    }

    /**
     * @param array<string, mixed> $scores
     * @return array<string, float>
     */
    public function normalizeScores(array $scores): array
    {
        $typedScores = $this->castToFloatScores($scores);
        $sum = array_sum($typedScores);

        if ($sum <= 0.0) {
            return [];
        }

        $normalized = [];
        foreach ($typedScores as $key => $score) {
            $normalized[$key] = $score / $sum;
        }

        return $normalized;
    }

    /**
     * @param array<string, mixed> $scores
     * @return array<string, float>
     */
    private function castToFloatScores(array $scores): array
    {
        $output = [];
        foreach ($scores as $key => $score) {
            if (is_numeric($score) && (float) $score > 0.0) {
                $output[(string) $key] = (float) $score;
            }
        }

        return $output;
    }
}
