// SPDX-FileCopyrightText: 2023 Gleb Smirnov <glebsmirnov0708@gmail.com>
//
// SPDX-License-Identifier: MIT

//! This crate provides actions provider type for script-based actions.

#![warn(
    missing_docs,
    clippy::missing_docs_in_private_items,
    clippy::missing_panics_doc
)]

use std::{
    collections::{HashMap, HashSet},
    ffi::OsStr,
    io,
    iter::once,
    path::PathBuf,
    sync::{Arc, Mutex, TryLockError},
};

use rustix::process::{kill_process, Pid, Signal};
use smol::process::{Command, Stdio};
use smol::{fs, prelude::*};
use textpieces_foundations::{
    Action, ActionProvider, ActionProviderMut, ActionProviderStop, ParameterValue,
};

use thiserror::Error;

/// Action provider for script-based actions.
#[derive(Debug)]
pub struct Scripts {
    /// Directory to get scripts from.
    directory: PathBuf,
    /// First arguments of script command line.
    launcher: Vec<String>,
    /// PIDs of running children.
    running_children: Arc<Mutex<HashSet<Pid>>>,
}

impl Scripts {
    /// Creates new action provider.
    ///
    /// # Arguments
    ///
    /// - `directory` - Path where scripts are stored. Index of scripts
    ///   is stored at `{directory}/scripts.json` file, scripts themselves are
    ///   stored in `{directory}/scripts` directory.
    /// - `launcher` - Script command line prefix. Scripts are launched
    ///   using `launcher` as command line and the script path as the last
    ///   argument.
    pub fn new(directory: impl AsRef<OsStr>, launcher: Option<&[&str]>) -> Self {
        Self {
            directory: PathBuf::from(&directory),
            launcher: launcher
                .unwrap_or_default()
                .iter()
                .map(|&s| s.to_owned())
                .collect(),
            running_children: Default::default(),
        }
    }

    /// Returns path of scripts index.
    pub fn index_file(&self) -> PathBuf {
        let mut path = self.directory.clone();
        path.push("scripts.json");
        path
    }

    /// Returns path of directory containing scripts themselves.
    pub fn scripts_dir(&self) -> PathBuf {
        let mut path = self.directory.clone();
        path.push("scripts");
        path
    }

    /// Returns path of script file.
    pub fn script_file(&self, action_id: &str) -> PathBuf {
        let mut path = self.scripts_dir();
        path.push(action_id);
        path
    }
}

/// Error type for script loading.
#[derive(Debug, Error)]
pub enum LoadScriptsError {
    /// I/O error occured while reading metadata file.
    #[error("failed to load actions metadata: {0}")]
    Io(#[from] std::io::Error),
    /// Failed to deserialize metadata from JSON.
    #[error("failed to deserialize actions metadata: {0}")]
    Json(#[from] serde_json::Error),
}

/// Error type for modifying script storage.
#[derive(Debug, Error)]
pub enum SaveScriptsError {
    /// Failed to serialize actions into JSON.
    #[error("failed to serialize actions metadata into JSON: {0}")]
    Json(#[from] serde_json::Error),
    /// I/O error occured while saving actions metadata to file.
    #[error("failed to save actions metadata: {0}")]
    Io(#[from] std::io::Error),
}

impl ActionProvider for Scripts {
    type LoadActionsError = LoadScriptsError;

    async fn load_actions(&self) -> Result<HashMap<String, Action>, Self::LoadActionsError> {
        if !self.index_file().exists() {
            return Ok(HashMap::new());
        }

        let contents = fs::read(self.index_file()).await?;
        let actions = serde_json::from_slice(&contents)?;

        Ok(actions)
    }

    async fn perform_action(
        &self,
        action_id: &str,
        parameters: &[ParameterValue],
        input: &str,
    ) -> Result<String, String> {
        let script_path = self.script_file(action_id);

        let mut args = self
            .launcher
            .iter()
            .map(AsRef::as_ref)
            .chain(once(script_path.as_os_str()));

        let mut child = Command::new(args.next().unwrap())
            .stdin(Stdio::piped())
            .stderr(Stdio::piped())
            .stdout(Stdio::piped())
            .kill_on_drop(true)
            .reap_on_drop(true)
            .args(args)
            .args(parameters.iter().map(ToString::to_string))
            .spawn()
            .map_err(|err| format!("Failed to run script: {err}"))?;

        let child_pid = Pid::from_raw(child.id() as i32).unwrap();

        self.running_children.lock().unwrap().insert(child_pid);

        let stdin = child
            .stdin
            .as_mut()
            .ok_or_else(|| String::from("Failed to get script process stdin"))?;
        stdin
            .write_all(input.as_bytes())
            .await
            .map_err(|err| format!("Failed to write to script's stdin: {err}"))?;

        let output = child.output().await.map_err(|err| err.to_string())?;
        self.running_children.lock().unwrap().remove(&child_pid);

        if output.status.success() {
            String::from_utf8(output.stdout)
                .map_err(|err| format!("Failed to decode script output: {err}"))
        } else {
            Err(String::from_utf8(output.stderr)
                .map_err(|err| format!("Failed to decode error message: {err}"))?)
        }
    }
}

/// Error type for stopping script actions.
#[allow(missing_docs, reason = "they have messages")]
#[derive(Debug, Error)]
pub enum StopError {
    #[error(transparent)]
    Io(#[from] io::Error),

    #[error("children table is locked")]
    LockedMutex,

    #[error("children table mutex is poisoned")]
    PoisonedMutex,
}

impl ActionProviderStop for Scripts {
    type StopError = StopError;

    fn stop(&self) -> Result<(), Self::StopError> {
        // Let's kill children!
        for &child in &*self.running_children.try_lock().map_err(|err| match err {
            TryLockError::Poisoned(_) => StopError::PoisonedMutex,
            TryLockError::WouldBlock => StopError::LockedMutex,
        })? {
            kill_process(child, Signal::KILL).map_err(io::Error::from)?;
        }

        Ok(())
    }
}

impl ActionProviderMut for Scripts {
    type SaveActionsError = SaveScriptsError;

    async fn update_actions(
        &self,
        actions: &HashMap<String, Action>,
    ) -> Result<(), Self::SaveActionsError> {
        let contents = serde_json::to_vec(&actions)?;

        if let Some(dir) = self.index_file().parent() {
            if !dir.exists() {
                fs::create_dir_all(dir).await?;
            }
        }

        fs::write(self.index_file(), contents).await?;

        Ok(())
    }
}
