// yt - A fully featured command line YouTube client
//
// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of Yt.
//
// You should have received a copy of the License along with this program.
// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.

use std::{collections::HashMap, env::current_exe, time::Duration, usize};

use anyhow::{bail, Result};
use libmpv2::{
    events::{Event, PlaylistEntryId},
    mpv_node::MpvNode,
    EndFileReason, Mpv,
};
use log::{debug, info, warn};
use tokio::{process::Command, time};

use crate::{
    app::App,
    comments::get_comments,
    storage::video_database::{
        extractor_hash::ExtractorHash,
        getters::{get_video_by_hash, get_video_mpv_opts, get_videos},
        setters::{set_state_change, set_video_watched},
        VideoStatus,
    },
};

#[derive(Debug)]
pub struct MpvEventHandler {
    watch_later_block_list: HashMap<ExtractorHash, ()>,
    // current_playlist: HashMap<PlaylistEntryId, ExtractorHash>,
    playlist_cache: HashMap<String, ExtractorHash>,
}

impl MpvEventHandler {
    pub fn from_playlist(playlist_cache: HashMap<String, ExtractorHash>) -> Self {
        Self {
            // current_playlist,
            playlist_cache,
            watch_later_block_list: HashMap::new(),
        }
    }

    fn get_current_mpv_playlist(
        &self,
        mpv: &Mpv,
    ) -> Result<HashMap<PlaylistEntryId, ExtractorHash>> {
        let mpv_playlist: Vec<(String, PlaylistEntryId)> = match mpv.get_property("playlist")? {
            MpvNode::ArrayIter(array) => array
                .map(|val| match val {
                    MpvNode::MapIter(map) => {
                        struct BuildPlaylistEntry {
                            filename: Option<String>,
                            id: Option<PlaylistEntryId>,
                        }
                        let mut entry = BuildPlaylistEntry {
                            filename: None,
                            id: None,
                        };

                        map.for_each(|(key, value)| match key.as_str() {
                            "filename" => {
                                entry.filename = Some(value.str().expect("work").to_owned())
                            }
                            "id" => {
                                entry.id = Some(PlaylistEntryId::new(value.i64().expect("Works")))
                            }
                            _ => (),
                        });
                        (entry.filename.expect("is some"), entry.id.expect("is some"))
                    }
                    _ => unreachable!(),
                })
                .collect(),
            _ => unreachable!(),
        };

        let mut playlist: HashMap<PlaylistEntryId, ExtractorHash> =
            HashMap::with_capacity(mpv_playlist.len());
        for (path, key) in mpv_playlist {
            let hash = self
                .playlist_cache
                .get(&path)
                .expect("All path should also be stored in the cache")
                .to_owned();
            playlist.insert(key, hash);
        }

        // debug!("Requested the current playlist: '{:#?}'", &playlist);

        Ok(playlist)
    }

    /// Checks, whether new videos are ready to be played
    pub async fn possibly_add_new_videos(
        &mut self,
        app: &App,
        mpv: &Mpv,
        force_message: bool,
    ) -> Result<usize> {
        let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?;

        // There is nothing to watch
        if play_things.len() == 0 {
            if force_message {
                Self::message(&mpv, "No new videos available to add", "3000")?;
            }
            return Ok(0);
        }

        let mut blocked_videos = 0;
        let current_playlist = self.get_current_mpv_playlist(mpv)?;
        let play_things = play_things
            .into_iter()
            .filter(|val| {
                !current_playlist
                    .values()
                    .find(|a| *a == &val.extractor_hash)
                    .is_some()
            })
            .filter(|val| {
                if self
                    .watch_later_block_list
                    .contains_key(&val.extractor_hash)
                {
                    blocked_videos += 1;
                    false
                } else {
                    true
                }
            })
            .collect::<Vec<_>>();

        info!(
            "{} videos are cached and will be added to the list to be played ({} are blocked)",
            play_things.len(),
            blocked_videos
        );

        self.playlist_cache.reserve(play_things.len());

        let num = play_things.len();
        for play_thing in play_things {
            debug!("Adding '{}' to playlist.", play_thing.title);

            let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some");
            let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8");
            let fmt_cache_path = format!("\"{}\"", cache_path);

            let args = &[&fmt_cache_path, "append-play"];

            mpv.execute("loadfile", args)?;
            self.playlist_cache
                .insert(cache_path.to_owned(), play_thing.extractor_hash);
        }

        if force_message || num > 0 {
            Self::message(
                &mpv,
                format!(
                    "Added {} videos ({} are marked as watch later)",
                    num, blocked_videos
                )
                .as_str(),
                "3000",
            )?;
        }
        Ok(num)
    }

    fn message(mpv: &Mpv, message: &str, time: &str) -> Result<()> {
        mpv.execute("show-text", &[format!("\"{}\"", message).as_str(), time])?;
        Ok(())
    }

    /// Get the hash of the currently playing video.
    /// You can specify an offset, which is added to the playlist_position to get, for example, the
    /// previous video (-1) or the next video (+1).
    /// Beware that setting an offset can cause an property error if it's out of bound.
    fn get_cvideo_hash(&self, mpv: &Mpv, offset: i64) -> Result<ExtractorHash> {
        let playlist_entry_id = {
            let playlist_position = {
                let raw = mpv.get_property::<i64>("playlist-pos")?;
                if raw == -1 {
                    unreachable!( "This should only be called when a current video exists. Current state: '{:#?}'", self);
                } else {
                    (raw + offset) as usize
                }
            };

            let raw =
                mpv.get_property::<i64>(format!("playlist/{}/id", playlist_position).as_str())?;
            PlaylistEntryId::new(raw)
        };

        // debug!("Trying to get playlist entry: '{}'", playlist_entry_id);

        let video_hash = self
            .get_current_mpv_playlist(mpv)?
            .remove(&playlist_entry_id)
            .expect("The stored playling index should always be in the playlist");

        Ok(video_hash)
    }
    async fn mark_video_watched(&self, app: &App, hash: &ExtractorHash) -> Result<()> {
        let video = get_video_by_hash(app, hash).await?;
        debug!("MPV handler will mark video '{}' watched.", video.title);
        set_video_watched(&app, &video).await?;
        Ok(())
    }

    async fn mark_video_inactive(
        &mut self,
        app: &App,
        mpv: &Mpv,
        playlist_index: PlaylistEntryId,
    ) -> Result<()> {
        let current_playlist = self.get_current_mpv_playlist(mpv)?;
        let video_hash = current_playlist
            .get(&playlist_index)
            .expect("The video index should always be correctly tracked");

        set_state_change(&app, video_hash, false).await?;
        Ok(())
    }
    async fn mark_video_active(
        &mut self,
        app: &App,
        mpv: &Mpv,
        playlist_index: PlaylistEntryId,
    ) -> Result<()> {
        let current_playlist = self.get_current_mpv_playlist(mpv)?;
        let video_hash = current_playlist
            .get(&playlist_index)
            .expect("The video index should always be correctly tracked");

        set_state_change(&app, video_hash, true).await?;
        Ok(())
    }

    /// Apply the options set with e.g. `watch --speed=<speed>`
    async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> {
        let options = get_video_mpv_opts(app, hash).await?;

        mpv.set_property("speed", options.playback_speed)?;
        Ok(())
    }

    /// This also returns the hash of the current video
    fn remove_cvideo_from_playlist(&mut self, mpv: &Mpv) -> Result<ExtractorHash> {
        let hash = self.get_cvideo_hash(mpv, 0)?;
        mpv.execute("playlist-remove", &["current"])?;
        Ok(hash)
    }

    /// Check if the playback queue is empty
    pub async fn check_idle(&mut self, app: &App, mpv: &Mpv) -> Result<bool> {
        if mpv.get_property::<bool>("idle-active")? {
            warn!("There is nothing to watch yet. Will idle, until something is available");
            let number_of_new_videos = self.possibly_add_new_videos(app, mpv, false).await?;

            if number_of_new_videos == 0 {
                time::sleep(Duration::from_secs(10)).await;
                Ok(true)
            } else {
                Ok(false)
            }
        } else {
            Ok(false)
        }
    }

    /// This will return [`true`], if the event handling should be stopped
    pub async fn handle_mpv_event<'a>(
        &mut self,
        app: &App,
        mpv: &Mpv,
        event: Event<'a>,
    ) -> Result<bool> {
        match event {
            Event::EndFile(r) => match r.reason {
                EndFileReason::Eof => {
                    info!("Mpv reached eof of current video. Marking it inactive.");

                    self.mark_video_inactive(app, mpv, r.playlist_entry_id)
                        .await?;
                }
                EndFileReason::Stop => {
                    // This reason is incredibly ambiguous. It _both_ means actually pausing a
                    // video and going to the next one in the playlist.
                    // Oh, and it's also called, when a video is removed from the playlist (at
                    // least via "playlist-remove current")
                    info!("Paused video (or went to next playlist entry); Marking it inactive");

                    self.mark_video_inactive(app, mpv, r.playlist_entry_id)
                        .await?;
                }
                EndFileReason::Quit => {
                    info!("Mpv quit. Exiting playback");

                    // draining the playlist is okay, as mpv is done playing
                    let videos = self.get_current_mpv_playlist(mpv)?;
                    for (_, hash) in videos {
                        self.mark_video_watched(app, &hash).await?;
                        set_state_change(&app, &hash, false).await?;
                    }
                    return Ok(true);
                }
                EndFileReason::Error => {
                    unreachable!("This will be raised as a separate error")
                }
                EndFileReason::Redirect => {
                    todo!("We probably need to handle this somehow");
                }
            },
            Event::StartFile(entry_id) => {
                self.possibly_add_new_videos(app, &mpv, false).await?;

                // We don't need to check, whether other videos are still active, as they should
                // have been marked inactive in the `Stop` handler.
                self.mark_video_active(app, mpv, entry_id).await?;
                self.apply_options(app, mpv, &self.get_cvideo_hash(mpv, 0)?)
                    .await?;
            }
            Event::ClientMessage(a) => {
                debug!("Got Client Message event: '{}'", a.join(" "));

                match a.as_slice() {
                    &["yt-comments-external"] => {
                        let binary = current_exe().expect("A current exe should exist");

                        let status = Command::new("riverctl")
                            .args(["focus-output", "next"])
                            .status()
                            .await?;
                        if !status.success() {
                            bail!("focusing the next output failed!");
                        }

                        let status = Command::new("alacritty")
                            .args(&[
                                "--title",
                                "floating please",
                                "--command",
                                binary.to_str().expect("Should be valid unicode"),
                                "--db-path",
                                app.config
                                    .paths
                                    .database_path
                                    .to_str()
                                    .expect("This should be convertible?"),
                                "comments",
                            ])
                            .status()
                            .await?;
                        if !status.success() {
                            bail!("Falied to start `yt comments`");
                        }

                        let status = Command::new("riverctl")
                            .args(["focus-output", "next"])
                            .status()
                            .await?;
                        if !status.success() {
                            bail!("focusing the next output failed!");
                        }
                    }
                    &["yt-comments-local"] => {
                        let comments: String = get_comments(app)
                            .await?
                            .render(false)
                            .replace("\"", "")
                            .replace("'", "")
                            .chars()
                            .take(app.config.watch.local_comments_length)
                            .collect();

                        Self::message(mpv, &comments, "6000")?;
                    }
                    &["yt-description"] => {
                        // let description = description(app).await?;
                        Self::message(&mpv, "<YT Description>", "6000")?;
                    }
                    &["yt-mark-watch-later"] => {
                        mpv.execute("write-watch-later-config", &[])?;

                        let hash = self.remove_cvideo_from_playlist(mpv)?;
                        assert_eq!(
                            self.watch_later_block_list.insert(hash, ()),
                            None,
                            "A video should not be blocked *and* in the playlist"
                        );

                        Self::message(&mpv, "Marked the video to be watched later", "3000")?;
                    }
                    &["yt-mark-done-and-go-next"] => {
                        let cvideo_hash = self.remove_cvideo_from_playlist(mpv)?;
                        self.mark_video_watched(app, &cvideo_hash).await?;

                        Self::message(&mpv, "Marked the video watched", "3000")?;
                    }
                    &["yt-check-new-videos"] => {
                        self.possibly_add_new_videos(app, mpv, true).await?;
                    }
                    other => {
                        debug!("Unknown message: {}", other.join(" "))
                    }
                }
            }
            _ => {}
        }

        Ok(false)
    }
}