<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities;
use Chandler\Database\DatabaseConnection;
use openvk\Web\Util\Shell\Exceptions\UnknownCommandException;
use openvk\Web\Util\Shell\Shell;

/**
 * @method setName(string)
 * @method setPerformer(string)
 * @method setLyrics(string)
 * @method setExplicit(bool)
 */
class Audio extends Media
{
    protected $tableName     = "audios";
    protected $fileExtension = "mpd";

    # Taken from winamp :D
    const genres = [
        'Blues','Big Band','Classic Rock','Chorus','Country','Easy Listening','Dance','Acoustic','Disco','Humour','Funk','Speech','Grunge','Chanson','Hip-Hop','Opera','Jazz','Chamber Music','Metal','Sonata','New Age','Symphony','Oldies','Booty Bass','Other','Primus','Pop','Porn Groove','R&B','Satire','Rap','Slow Jam','Reggae','Club','Rock','Tango','Techno','Samba','Industrial','Folklore','Alternative','Ballad','Ska','Power Ballad','Death Metal','Rhythmic Soul','Pranks','Freestyle','Soundtrack','Duet','Euro-Techno','Punk Rock','Ambient','Drum Solo','Trip-Hop','A Cappella','Vocal','Euro-House','Jazz+Funk','Dance Hall','Fusion','Goa','Trance','Drum & Bass','Classical','Club-House','Instrumental','Hardcore','Acid','Terror','House','Indie','Game','BritPop','Sound Clip','Negerpunk','Gospel','Polsk Punk','Noise','Beat','AlternRock','Christian Gangsta Rap','Bass','Heavy Metal','Soul','Black Metal','Punk','Crossover','Space','Contemporary Christian','Meditative','Christian Rock','Instrumental Pop','Merengue','Instrumental Rock','Salsa','Ethnic','Thrash Metal','Gothic','Anime','Darkwave','JPop','Techno-Industrial','Synthpop','Electronic','Abstract','Pop-Folk','Art Rock','Eurodance','Baroque','Dream','Bhangra','Southern Rock','Big Beat','Comedy','Breakbeat','Cult','Chillout','Gangsta Rap','Downtempo','Top 40','Dub','Christian Rap','EBM','Pop / Funk','Eclectic','Jungle','Electro','Native American','Electroclash','Cabaret','Emo','New Wave','Experimental','Psychedelic','Garage','Rave','Global','Showtunes','IDM','Trailer','Illbient','Lo-Fi','Industro-Goth','Tribal','Jam Band','Acid Punk','Krautrock','Acid Jazz','Leftfield','Polka','Lounge','Retro','Math Rock','Musical','New Romantic','Rock & Roll','Nu-Breakz','Hard Rock','Post-Punk','Folk','Post-Rock','Folk-Rock','Psytrance','National Folk','Shoegaze','Swing','Space Rock','Fast Fusion','Trop Rock','Bebob','World Music','Latin','Neoclassical','Revival','Audiobook','Celtic','Audio Theatre','Bluegrass','Neue Deutsche Welle','Avantgarde','Podcast','Gothic Rock','Indie Rock','Progressive Rock','G-Funk','Psychedelic Rock','Dubstep','Symphonic Rock','Garage Rock','Slow Rock','Psybient','Psychobilly','Touhou'
    ];

    # Taken from: https://web.archive.org/web/20220322153107/https://dev.vk.com/reference/objects/audio-genres
    const vkGenres = [
        "Rock"               => 1,
        "Pop"                => 2,
        "Rap"                => 3,
        "Hip-Hop"            => 3, # VK API lists №3 as Rap & Hip-Hop, but these genres are distinct in OpenVK
        "Easy Listening"     => 4,
        "House"              => 5,
        "Dance"              => 5,
        "Instrumental"       => 6,
        "Metal"              => 7,
        "Alternative"        => 21,
        "Dubstep"            => 8,
        "Jazz"               => 1001,
        "Blues"              => 1001,
        "Drum & Bass"        => 10,
        "Trance"             => 11,
        "Chanson"            => 12,
        "Ethnic"             => 13,
        "Acoustic"           => 14,
        "Vocal"              => 14,
        "Reggae"             => 15,
        "Classical"          => 16,
        "Indie Pop"          => 17,
        "Speech"             => 19,
        "Disco"              => 22,
        "Other"              => 18,
    ];

    private function fileLength(string $filename): int
    {
        if(!Shell::commandAvailable("ffmpeg") || !Shell::commandAvailable("ffprobe"))
            throw new \Exception();

        $error   = NULL;
        $streams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams a", "-loglevel error")->execute($error);
        if($error !== 0)
            throw new \DomainException("$filename is not recognized as media container");
        else if(empty($streams) || ctype_space($streams))
            throw new \DomainException("$filename does not contain any audio streams");

        $vstreams = Shell::ffprobe("-i", $filename, "-show_streams", "-select_streams v", "-loglevel error")->execute($error);
        
        # check if audio has cover (attached_pic)
        preg_match("%attached_pic=([0-1])%", $vstreams, $hasCover);
        if(!empty($vstreams) && !ctype_space($vstreams) && ((int)($hasCover[1]) !== 1))
            throw new \DomainException("$filename is a video");

        $durations = [];
        preg_match_all('%duration=([0-9\.]++)%', $streams, $durations);
        if(sizeof($durations[1]) === 0)
            throw new \DomainException("$filename does not contain any meaningful audio streams");

        $length = 0;
        foreach($durations[1] as $duration) {
            $duration = floatval($duration);
            if($duration < 1.0 || $duration > 65536.0)
                throw new \DomainException("$filename does not contain any meaningful audio streams");
            else
                $length = max($length, $duration);
        }

        return (int) round($length, 0, PHP_ROUND_HALF_EVEN);
    }

    /**
     * @throws \Exception
     */
    protected function saveFile(string $filename, string $hash): bool
    {
        $duration = $this->fileLength($filename);

        $kid = openssl_random_pseudo_bytes(16);
        $key = openssl_random_pseudo_bytes(16);
        $tok = openssl_random_pseudo_bytes(28);
        $ss  = ceil($duration / 15);

        $this->stateChanges("kid", $kid);
        $this->stateChanges("key", $key);
        $this->stateChanges("token", $tok);
        $this->stateChanges("segment_size", $ss);
        $this->stateChanges("length", $duration);

        try {
            $args = [
                str_replace("enabled", "available", OPENVK_ROOT),
                str_replace("enabled", "available", $this->getBaseDir()),
                $hash,
                $filename,

                bin2hex($kid),
                bin2hex($key),
                bin2hex($tok),
                $ss,
            ];

            if(Shell::isPowershell()) {
                Shell::powershell("-executionpolicy bypass", "-File", __DIR__ . "/../shell/processAudio.ps1", ...$args)
                ->start();
            } else {
                Shell::bash(__DIR__ . "/../shell/processAudio.sh", ...$args) // Pls workkkkk
                ->start();                                                    // idk, not tested :")
            }

            # Wait until processAudio will consume the file
            $start = time();
            while(file_exists($filename))
                if(time() - $start > 5)
                    throw new \RuntimeException("Timed out waiting FFMPEG");

         } catch(UnknownCommandException $ucex) {
             exit(OPENVK_ROOT_CONF["openvk"]["debug"] ? "bash/pwsh is not installed" : VIDEOS_FRIENDLY_ERROR);
         }

        return true;
    }

    function getTitle(): string
    {
        return $this->getRecord()->name;
    }

    function getPerformer(): string
    {
        return $this->getRecord()->performer;
    }

    function getName(): string
    {
        return $this->getPerformer() . " — " . $this->getTitle();
    }

    function getGenre(): ?string
    {
        return $this->getRecord()->genre;
    }

    function getLyrics(): ?string
    {
        return !is_null($this->getRecord()->lyrics) ? htmlspecialchars($this->getRecord()->lyrics, ENT_DISALLOWED | ENT_XHTML) : NULL;
    }

    function getLength(): int
    {
        return $this->getRecord()->length;
    }

    function getFormattedLength(): string
    {
        $len  = $this->getLength();
        $mins = floor($len / 60);
        $secs = $len - ($mins * 60);

        return (
            str_pad((string) $mins, 2, "0", STR_PAD_LEFT)
            . ":" .
            str_pad((string) $secs, 2, "0", STR_PAD_LEFT)
        );
    }

    function getSegmentSize(): float
    {
        return $this->getRecord()->segment_size;
    }

    function getListens(): int
    {
        return $this->getRecord()->listens;
    }

    function getOriginalURL(bool $force = false): string
    {
        $disallowed = !OPENVK_ROOT_CONF["openvk"]["preferences"]["music"]["exposeOriginalURLs"] && !$force;
        if(!$this->isAvailable() || $disallowed)
            return ovk_scheme(true)
                . $_SERVER["HTTP_HOST"] . ":"
                . $_SERVER["HTTP_PORT"]
                . "/assets/packages/static/openvk/audio/nomusic.mp3";

        $key = bin2hex($this->getRecord()->token);

        return str_replace(".mpd", "_fragments", $this->getURL()) . "/original_$key.mp3";
    }

    function getURL(?bool $force = false): string
    {
        if ($this->isWithdrawn()) return "";

        return parent::getURL();
    }

    function getKeys(): array
    {
        $keys[bin2hex($this->getRecord()->kid)] = bin2hex($this->getRecord()->key);

        return $keys;
    }

    function isAnonymous(): bool
    {
        return false;
    }

    function isExplicit(): bool
    {
        return (bool) $this->getRecord()->explicit;
    }

    function isWithdrawn(): bool
    {
        return (bool) $this->getRecord()->withdrawn;
    }

    function isUnlisted(): bool
    {
        return (bool) $this->getRecord()->unlisted;
    }

    # NOTICE may flush model to DB if it was just processed
    function isAvailable(): bool
    {
        if($this->getRecord()->processed)
            return true;

        # throttle requests to isAvailable to prevent DoS attack if filesystem is actually an S3 storage
        if(time() - $this->getRecord()->checked < 5)
            return false;

        try {
            $fragments = str_replace(".mpd", "_fragments", $this->getFileName());
            $original = "original_" . bin2hex($this->getRecord()->token) . ".mp3";
            if(file_exists("$fragments/$original")) {
                # Original gets uploaded after fragments
                $this->stateChanges("processed", 0x01);

                return true;
            }
        } finally {
            $this->stateChanges("checked", time());
            $this->save();
        }

        return false;
    }

    function isInLibraryOf($entity): bool
    {
        return sizeof(DatabaseConnection::i()->getContext()->table("audio_relations")->where([
            "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1),
            "audio"  => $this->getId(),
        ])) != 0;
    }

    function add($entity): bool
    {
        if($this->isInLibraryOf($entity))
            return false;

        $entityId  = $entity->getId() * ($entity instanceof Club ? -1 : 1);
        $audioRels = DatabaseConnection::i()->getContext()->table("audio_relations");
        if(sizeof($audioRels->where("entity", $entityId)) > 65536)
            throw new \OverflowException("Can't have more than 65536 audios in a playlist");

        $audioRels->insert([
            "entity" => $entityId,
            "audio"  => $this->getId(),
        ]);

        return true;
    }

    function remove($entity): bool
    {
        if(!$this->isInLibraryOf($entity))
            return false;

        DatabaseConnection::i()->getContext()->table("audio_relations")->where([
            "entity" => $entity->getId() * ($entity instanceof Club ? -1 : 1),
            "audio"  => $this->getId(),
        ])->delete();

        return true;
    }

    function listen($entity, Playlist $playlist = NULL): bool
    {
        $listensTable = DatabaseConnection::i()->getContext()->table("audio_listens");
        $lastListen   = $listensTable->where([
            "entity" => $entity->getRealId(),
            "audio"  => $this->getId(),
        ])->order("index DESC")->fetch();
        
        if(!$lastListen || (time() - $lastListen->time >= $this->getLength())) {
            $listensTable->insert([
                "entity" => $entity->getRealId(),
                "audio"  => $this->getId(),
                "time"   => time(),
                "playlist" => $playlist ? $playlist->getId() : NULL,
            ]);

            if($entity instanceof User) {
                $this->stateChanges("listens", ($this->getListens() + 1));
                $this->save();

                if($playlist) {
                    $playlist->incrementListens();
                    $playlist->save();
                }
            }

            $entity->setLast_played_track($this->getId());
            $entity->save();

            return true;
        }

        $lastListen->update([
            "time" => time(),
        ]);

        return false;
    }

    /**
     * Returns compatible with VK API 4.x, 5.x structure.
     *
     * Always sets album(_id) to NULL at this time.
     * If genre is not present in VK genre list, fallbacks to "Other".
     * The url and manifest properties will be set to false if the audio can't be played (processing, removed).
     *
     * Aside from standard VK properties, this method will also return some OVK extended props:
     * 1. added - Is in the library of $user?
     * 2. editable - Can be edited by $user?
     * 3. withdrawn - Removed due to copyright request?
     * 4. ready - Can be played at this time?
     * 5. genre_str - Full name of genre, NULL if it's undefined
     * 6. manifest - URL to MPEG-DASH manifest
     * 7. keys - ClearKey DRM keys
     * 8. explicit - Marked as NSFW?
     * 9. searchable - Can be found via search?
     * 10. unique_id - Unique ID of audio
     *
     * @notice that in case if exposeOriginalURLs is set to false in config, "url" will always contain link to nomusic.mp3,
     * unless $forceURLExposure is set to true.
     *
     * @notice may trigger db flush if the audio is not processed yet, use with caution on unsaved models.
     *
     * @param ?User $user user, relative to whom "added", "editable" will be set
     * @param bool $forceURLExposure force set "url" regardless of config
     */
    function toVkApiStruct(?User $user = NULL, bool $forceURLExposure = false): object
    {
        $obj = (object) [];
        $obj->unique_id  = base64_encode((string) $this->getId());
        $obj->id         = $obj->aid = $this->getVirtualId();
        $obj->artist     = $this->getPerformer();
        $obj->title      = $this->getTitle();
        $obj->duration   = $this->getLength();
        $obj->album_id   = $obj->album = NULL; # i forgor to implement
        $obj->url        = false;
        $obj->manifest   = false;
        $obj->keys       = false;
        $obj->genre_id   = $obj->genre = self::vkGenres[$this->getGenre() ?? ""] ?? 18; # return Other if no match
        $obj->genre_str  = $this->getGenre();
        $obj->owner_id   = $this->getOwner()->getId();
        if($this->getOwner() instanceof Club)
            $obj->owner_id *= -1;

        $obj->lyrics = NULL;
        if(!is_null($this->getLyrics()))
            $obj->lyrics = $this->getId();

        $obj->added      = $user && $this->isInLibraryOf($user);
        $obj->editable   = $user && $this->canBeModifiedBy($user);
        $obj->searchable = !$this->isUnlisted();
        $obj->explicit   = $this->isExplicit();
        $obj->withdrawn  = $this->isWithdrawn();
        $obj->ready      = $this->isAvailable() && !$obj->withdrawn;
        if($obj->ready) {
            $obj->url      = $this->getOriginalURL($forceURLExposure);
            $obj->manifest = $this->getURL();
            $obj->keys     = $this->getKeys();
        }

        return $obj;
    }

    function setOwner(int $oid): void
    {
        # WARNING: API implementation won't be able to handle groups like that, don't remove
        if($oid <= 0)
            throw new \OutOfRangeException("Only users can be owners of audio!");

        $this->stateChanges("owner", $oid);
    }

    function setGenre(string $genre): void
    {
        if(!in_array($genre, Audio::genres)) {
            $this->stateChanges("genre", NULL);
            return;
        }

        $this->stateChanges("genre", $genre);
    }

    function setCopyrightStatus(bool $withdrawn = true): void {
        $this->stateChanges("withdrawn", $withdrawn);
    }

    function setSearchability(bool $searchable = true): void {
        $this->stateChanges("unlisted", !$searchable);
    }

    function setToken(string $tok): void {
        throw new \LogicException("Changing keys is not supported.");
    }

    function setKid(string $kid): void {
        throw new \LogicException("Changing keys is not supported.");
    }

    function setKey(string $key): void {
        throw new \LogicException("Changing keys is not supported.");
    }

    function setLength(int $len): void {
        throw new \LogicException("Changing length is not supported.");
    }

    function setSegment_Size(int $len): void {
        throw new \LogicException("Changing length is not supported.");
    }

    function delete(bool $softly = true): void
    {
        $ctx = DatabaseConnection::i()->getContext();
        $ctx->table("audio_relations")->where("audio", $this->getId())
            ->delete();
        $ctx->table("audio_listens")->where("audio", $this->getId())
            ->delete();
        $ctx->table("playlist_relations")->where("media", $this->getId())
            ->delete();

        parent::delete($softly);
    }
}