<?php

namespace App\Services;

use App\Events\LibraryChanged;
use App\Facades\Dispatcher;
use App\Facades\License;
use App\Jobs\DeleteSongFilesJob;
use App\Jobs\DeleteTranscodeFilesJob;
use App\Jobs\ExtractSongFolderStructureJob;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Models\Transcode;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Repositories\TranscodeRepository;
use App\Services\Scanners\Contracts\ScannerCacheStrategy as CacheStrategy;
use App\Values\Scanning\ScanConfiguration;
use App\Values\Scanning\ScanInformation;
use App\Values\Song\SongFileInfo;
use App\Values\Song\SongUpdateData;
use App\Values\Song\SongUpdateResult;
use App\Values\Transcoding\TranscodeFileInfo;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;

class SongService
{
    public function __construct(
        private readonly SongRepository $songRepository,
        private readonly TranscodeRepository $transcodeRepository,
        private readonly AlbumService $albumService,
        private readonly CacheStrategy $cache,
    ) {
    }

    public function updateSongs(array $ids, SongUpdateData $data): SongUpdateResult
    {
        if (count($ids) === 1) {
            // If we're only updating one song, an empty non-required should be converted to the default values.
            // This allows the user to clear those fields.
            $data->disc = $data->disc ?: 1;
            $data->track = $data->track ?: 0;
            $data->lyrics = $data->lyrics ?: '';
            $data->year = $data->year ?: null;
            $data->genre = $data->genre ?: '';
            $data->albumArtistName = $data->albumArtistName ?: $data->artistName;
        }

        return DB::transaction(function () use ($ids, $data): SongUpdateResult {
            $result = SongUpdateResult::make();
            $multiSong = count($ids) > 1;
            $noTrackUpdate = $multiSong && !$data->track;
            $affectedAlbums = collect();
            $affectedArtists = collect();

            Song::query()->with('artist.user', 'album.artist', 'album.artist.user')->findMany($ids)->each(
                function (Song $song) use ($data, $result, $noTrackUpdate, $affectedAlbums, $affectedArtists): void {
                    if ($noTrackUpdate) {
                        $data->track = $song->track;
                    }

                    if ($affectedAlbums->pluck('id')->doesntContain($song->album_id)) {
                        $affectedAlbums->push($song->album);
                    }

                    if ($affectedArtists->pluck('id')->doesntContain($song->artist_id)) {
                        $affectedArtists->push($song->artist);
                    }

                    if ($affectedArtists->pluck('id')->doesntContain($song->album->artist_id)) {
                        $affectedArtists->push($song->album_artist);
                    }

                    $result->addSong($this->updateSong($song, clone $data)); // @phpstan-ignore-line

                    if ($noTrackUpdate) {
                        $data->track = null;
                    }
                },
            );

            $affectedAlbums->each(static function (Album $album) use ($result): void {
                if ($album->refresh()->songs()->count() === 0) {
                    $result->addRemovedAlbum($album);
                    $album->delete();
                }
            });

            $affectedArtists->each(static function (Artist $artist) use ($result): void {
                if ($artist->songs()->count() === 0 && $artist->albums()->count() === 0) {
                    $result->addRemovedArtist($artist);
                    $artist->delete();
                }
            });

            return $result;
        });
    }

    private function updateSong(Song $song, SongUpdateData $data): Song
    {
        // For non-nullable fields, if the provided data is empty, use the existing value
        $data->albumName = $data->albumName ?: $song->album->name;
        $data->artistName = $data->artistName ?: $song->artist->name;
        $data->title = $data->title ?: $song->title;

        // For nullable fields, use the existing value only if the provided data is explicitly null
        // (i.e., when multiple songs are being updated and the user did not provide a value).
        // This allows us to clear those fields (when the user provides an empty string).
        $data->albumArtistName ??= $song->album_artist->name;
        $data->lyrics ??= $song->lyrics;
        $data->track ??= $song->track;
        $data->disc ??= $song->disc;
        $data->genre ??= $song->genre;
        $data->year ??= $song->year;

        $albumArtist = Artist::getOrCreate($song->album_artist->user, $data->albumArtistName);
        $artist = Artist::getOrCreate($song->artist->user, $data->artistName);
        $album = Album::getOrCreate($albumArtist, $data->albumName);

        $song->album_id = $album->id;
        $song->album_name = $album->name;
        $song->artist_id = $artist->id;
        $song->artist_name = $artist->name;
        $song->title = $data->title;
        $song->lyrics = $data->lyrics;
        $song->track = $data->track;
        $song->disc = $data->disc;
        $song->year = $data->year;

        $song->push();

        if (!$song->genreEqualsTo($data->genre)) {
            $song->syncGenres($data->genre);
        }

        return $this->songRepository->getOne($song->id);
    }

    public function markSongsAsPublic(EloquentCollection $songs): void
    {
        $songs->toQuery()->update(['is_public' => true]);
    }

    /** @return array<string> IDs of songs that are marked as private */
    public function markSongsAsPrivate(EloquentCollection $songs): array
    {
        License::requirePlus();

        // Songs that are in collaborative playlists can't be marked as private.
        /**
         * @var Collection<array-key, Song> $collaborativeSongs
         */
        $collaborativeSongs = $songs->toQuery()
            ->join('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
            ->join('playlist_user', 'playlist_song.playlist_id', '=', 'playlist_user.playlist_id')
            ->select('songs.id')
            ->distinct()
            ->pluck('songs.id')
            ->all();

        $applicableSongIds = $songs->whereNotIn('id', $collaborativeSongs)->modelKeys();

        Song::query()->whereKey($applicableSongIds)->update(['is_public' => false]);

        return $applicableSongIds;
    }

    /**
     * @param array<string>|string $ids
     */
    public function deleteSongs(array|string $ids): void
    {
        $ids = Arr::wrap($ids);

        // Since song (and cascadingly, transcode) records will be deleted, we query them first and, if there are any,
        // dispatch a job to delete their associated files.
        $songFiles = Song::query()
            ->findMany($ids)
            ->map(static fn (Song $song) => SongFileInfo::fromSong($song)); // @phpstan-ignore-line

        $transcodeFiles = $this->transcodeRepository->findBySongIds($ids)
            ->map(static fn (Transcode $transcode) => TranscodeFileInfo::fromTranscode($transcode)); // @phpstan-ignore-line

        if (Song::destroy($ids) === 0) {
            return;
        }

        Dispatcher::dispatch(new DeleteSongFilesJob($songFiles));

        if ($transcodeFiles->isNotEmpty()) {
            Dispatcher::dispatch(new DeleteTranscodeFilesJob($transcodeFiles));
        }

        // Instruct the system to prune the library, i.e., remove empty albums and artists.
        event(new LibraryChanged());
    }

    public function createOrUpdateSongFromScan(
        ScanInformation $info,
        ScanConfiguration $config,
        ?Song $song = null,
    ): ?Song {
        $song ??= $this->songRepository->findOneByPath($info->path);

        $isFileNew = !$song;
        $isFileModified = $song && $song->isFileModified($info->mTime);
        $isFileNewOrModified = $isFileNew || $isFileModified;

        if (!$isFileNewOrModified && !$config->force) {
            return $song;
        }

        $data = $info->toArray();
        $genre = Arr::pull($data, 'genre', '');

        // If the file is new, we take all necessary metadata, totally discarding the "ignores" config.
        // Otherwise, we only take the metadata not in the "ignores" config.
        if (!$isFileNew) {
            Arr::forget($data, $config->ignores);
        }

        $artist = $this->resolveArtist($config->owner, Arr::get($data, 'artist'));

        $albumArtist = Arr::get($data, 'albumartist')
            ? $this->resolveArtist($config->owner, $data['albumartist'])
            : $artist;

        $album = $this->resolveAlbum($albumArtist, Arr::get($data, 'album'));

        $hasCover = $album->cover && File::exists(image_storage_path($album->cover));

        if (!$hasCover && !in_array('cover', $config->ignores, true)) {
            $coverData = Arr::get($data, 'cover.data');

            if ($coverData) {
                $this->albumService->storeAlbumCover($album, $coverData);
            } else {
                $this->albumService->trySetAlbumCoverFromDirectory($album, dirname($data['path']));
            }
        }

        Arr::forget($data, ['album', 'artist', 'albumartist', 'cover']);

        $data['album_id'] = $album->id;
        $data['artist_id'] = $artist->id;
        $data['is_public'] = $config->makePublic;
        $data['album_name'] = $album->name;
        $data['artist_name'] = $artist->name;

        if ($isFileNew) {
            // Only set the owner if the song is new, i.e., don't override the owner if the song is being updated.
            $data['owner_id'] = $config->owner->id;
            /** @var Song $song */
            $song = Song::query()->create($data);
        } else {
            $song->update($data);
        }

        if ($genre !== $song->genre) {
            $song->syncGenres($genre);
        }

        if (!$album->year && $song->year) {
            $album->update(['year' => $song->year]);
        }

        if ($config->extractFolderStructure) {
            Dispatcher::dispatch(new ExtractSongFolderStructureJob($song));
        }

        return $song;
    }

    private function resolveArtist(User $user, ?string $name): Artist
    {
        $name = trim($name);

        return $this->cache->remember(
            key: cache_key(__METHOD__, $user->id, $name),
            callback: static fn () => Artist::getOrCreate($user, $name)
        );
    }

    private function resolveAlbum(Artist $artist, ?string $name): Album
    {
        $name = trim($name);

        return $this->cache->remember(
            key: cache_key(__METHOD__, $artist->id, $name),
            callback: static fn () => Album::getOrCreate($artist, $name)
        );
    }
}