// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright © 2024 Mike Robeson [dijksterhuis]
//! An interfaces for loading and interacting with an Octatrack project (whole
//! directory -- not just the `project.*` file!
//! ```rust
//! use ot_tools_interfaces::projects::OctatrackProject;
//! # use ot_tools_interfaces::OtToolsInterfacesError;
//! # fn main() -> Result<(), OtToolsInterfacesError> {
//! # use std::fs::{create_dir_all, remove_dir_all};
//! # let some_new_path = "/tmp/ot-tools-interfaces/docs/interfaces/demo";
//!
//! let project = OctatrackProject::load("./test-data/blank-project")?;
//! project.validate()?;
//! let mut cloned = project.to_new(some_new_path)?;
//! # if std::path::PathBuf::from(some_new_path).exists() { remove_dir_all(some_new_path)? };
//! create_dir_all(some_new_path)?;
//! cloned.save(true)?;
//! # Ok(()) }
//! ```

use crate::projects::markers::MarkersDataFile;
use crate::projects::projects::ProjDataFile;
use crate::projects::slots::Slots;
use crate::R;
use log::{error, trace};
use std::array::from_fn;
use std::path::{Path, PathBuf};

use crate::projects::ProjectsErrors;
use ot_tools_io::identifiers::{BankId, SavedState};
use ot_tools_io::types::{Arrangements, Banks};
use ot_tools_io::{ArrangementFile, BankFile, CheckFileIntegrity, ALLOWED_OS_VERSIONS};

/// Interface for loading and modifying data files for a specific Octatrack
/// Project directory for a specific project [`SavedState`].
///
/// # `markers.*` data access
///
/// There are no `markers`, `markers_ref` or `markers_mut` methods in this type.
/// All the markers file data is easily accessible via the [`Slots`] type,
/// available via [`Self::slots`], [`Self::slots_ref`] and [`Self::slots_mut`].
///
/// # DANGER: mutable reference `project.*` access
///
/// You are able to still update slot attributes data via the
/// [`Self::project_mut`] accessor method.
///
/// Any changes you make to slot attributes via this method will be overwritten
/// by the state of the [`Slots`] type when calling [`Self::save`].
///
/// Unfortunately, hiding access to just the slots field within [`ProjDataFile`]
/// is non-trivial, and the type provides access to other project metadata that
/// you may wish to change.
///
/// So you are getting a warning about it in the documentation for now.
///
/// ## !!! DO NOT DO THIS !!!
/// ```
/// # use ot_tools_interfaces::projects::OctatrackProjectState;
/// # use ot_tools_interfaces::OtToolsInterfacesError;
/// # use ot_tools_io::identifiers::SavedState;
/// # use ot_tools_io::types::{SlotAttributes, SlotMarkers, ActiveSlot, SlotType};
/// # fn main() -> Result<(), OtToolsInterfacesError> {
/// # let mut p = OctatrackProjectState::load("test-data/blank-project", &SavedState::Working)?;
/// // CHANGE WILL BE OVERWRITTEN WHEN CALLING `p.save()`
/// p.project_mut().data_mut().slots.static_slots[0] = Some(
///     SlotAttributes::new_no_opts(SlotType::Static, 1, None)?
/// );
/// # Ok(())}
/// ```
/// ## !!! DO THIS !!!
/// ```
/// # use ot_tools_interfaces::projects::OctatrackProjectState;
/// # use ot_tools_interfaces::OtToolsInterfacesError;
/// # use ot_tools_io::types::{SlotAttributes, SlotMarkers, ActiveSlot, SlotType};
/// # use ot_tools_io::identifiers::SavedState;
/// # fn main() -> Result<(), OtToolsInterfacesError> {
/// # let mut p = OctatrackProjectState::load("test-data/blank-project", &SavedState::Working)?;
/// // calling p.save() will save this change
/// p.slots_mut().static_slots_mut()[0] = Some(ActiveSlot::new(
///     SlotAttributes::new_no_opts(SlotType::Static, 1, None)?,
///     SlotMarkers::default(),
/// ));
/// # Ok(())}
/// ```
///
/// # Example
/// ```rust
/// use std::path::PathBuf;
/// use ot_tools_io::{HasChecksumField, IsDefault};
/// use ot_tools_interfaces::projects::OctatrackProjectState;
/// use ot_tools_io::identifiers::{SavedState, BankId};
/// # use ot_tools_interfaces::OtToolsInterfacesError;
/// # fn main() -> Result<(), OtToolsInterfacesError> {
/// # use std::fs::{create_dir_all, remove_dir_all};
/// #
/// # let project_path = std::env::temp_dir()
/// #     .join("ot-tools-interfaces")
/// #     .join("docs")
/// #     .join("interface")
/// #     .join("state");
/// #
/// # if project_path.exists() { remove_dir_all(&project_path)? };
/// # if !project_path.parent().unwrap().exists() { create_dir_all(project_path.parent().unwrap())? };
/// # copy_dir::copy_dir(
/// #     PathBuf::from("test-data").join("blank-project"),
/// #     &project_path,
/// # )?;
///
/// // -----------------------------------------------------------------------
/// // read only inspection
/// // -----------------------------------------------------------------------
///
/// let project_readonly = OctatrackProjectState::load(&project_path, &SavedState::Working)?;
/// project_readonly.validate()?;
/// assert!(
///     project_readonly
///         .banks_ref()
///         .id_ref(&BankId::A)
///         .is_default()
/// );
///
/// println!(
///     "bank header: {:?}",
///     project_readonly
///         .banks_ref()
///         .id_ref(&BankId::A)
///         .header
/// );
///
/// // -----------------------------------------------------------------------
/// // read and write
/// // -----------------------------------------------------------------------
///
/// let mut project_readwrite = OctatrackProjectState::load(&project_path, &SavedState::Working)?;
/// project_readwrite.validate()?;
///
/// // change the first bank's checksum to an incorrect value
/// let bank_data_mut = project_readwrite.banks_mut().id_mut(&BankId::A);
/// bank_data_mut.checksum = 0;
///
/// // should not pass validation
/// assert!(project_readwrite.validate().is_err());
///
/// // but we can still write the data
/// // -- checksums are updated on low-level file writes
/// // -- note: file headers/patch versions etc. are NOT updated on low-level file writes!
/// project_readwrite.save(false)?;
///
/// // change another value in the first bank, calculate and reset the checksum value
/// let bank_data_mut = project_readwrite.banks_mut().id_mut(&BankId::A);
/// bank_data_mut.parts_edited_bitmask = 15;
/// let new_checksum = bank_data_mut.calculate_checksum()?;
/// bank_data_mut.checksum = new_checksum;
///
/// // should pass validationa
/// assert!(project_readwrite.validate().is_ok());
///
/// // write the data
/// project_readwrite.save(false)?;
/// # Ok(())}
/// ```
#[derive(Clone, PartialEq, Debug, Hash, PartialOrd, Ord, Eq)]
pub struct OctatrackProjectState {
    project: ProjDataFile,
    banks: Banks<BankFile>,
    // arrangements will always write badly as checksums have not been implemented
    arrangements: Arrangements<ArrangementFile>,
    markers: MarkersDataFile,
    slots: Slots,
    dirpath: PathBuf,
    state: SavedState,
}

impl OctatrackProjectState {
    /// Owned access to project file data
    pub fn project(self) -> ProjDataFile {
        self.project
    }

    /// Reference access to project file data
    pub fn project_ref(&self) -> &ProjDataFile {
        &self.project
    }

    /// Mutable reference access to project file data
    ///
    /// # DANGER
    ///
    /// Do not update slot attribute data via this method.
    /// Your changes will be written over.
    ///
    /// Use the [`Self::slots_mut`] method instead.
    /// See the explanation in [`Slots`] and [`Self`] for more information.
    ///
    /// (I wish I could make it so that I didn't have to warn you,
    /// but at this moment in time this is about the best i can do).
    pub fn project_mut(&mut self) -> &mut ProjDataFile {
        &mut self.project
    }

    /// Owned access to all banks
    pub fn banks(self) -> Banks<BankFile> {
        self.banks
    }

    /// Reference access to all banks
    pub fn banks_ref(&self) -> &Banks<BankFile> {
        &self.banks
    }

    /// Mutable reference access to all banks
    pub fn banks_mut(&mut self) -> &mut Banks<BankFile> {
        &mut self.banks
    }

    /// Owned access to all arrangements
    pub fn arrangements(self) -> Arrangements<ArrangementFile> {
        self.arrangements
    }

    /// Reference access to all arrangements
    pub fn arrangements_ref(&self) -> &Arrangements<ArrangementFile> {
        &self.arrangements
    }

    /// Mutable reference access to all arrangements
    pub fn arrangements_mut(&mut self) -> &mut Arrangements<ArrangementFile> {
        &mut self.arrangements
    }

    // **DO NOT** provide PUBLIC access to markers data -- use the `slots_*` methods instead
    #[allow(dead_code)]
    fn markers(self) -> MarkersDataFile {
        self.markers
    }

    // **DO NOT** provide PUBLIC access to markers data -- use the `slots_*` methods instead
    #[allow(dead_code)]
    fn markers_ref(&self) -> &MarkersDataFile {
        &self.markers
    }

    // **DO NOT** provide PUBLIC access to markers data -- use the `slots_*` methods instead
    fn markers_mut(&mut self) -> &mut MarkersDataFile {
        &mut self.markers
    }

    /// Saved state of the project
    pub fn state(&self) -> &SavedState {
        &self.state
    }

    /// Reference to the path of the project directory
    pub fn dirpath_ref(&self) -> &Path {
        &self.dirpath
    }

    /// Owned path of the project directory
    pub fn dirpath(&self) -> PathBuf {
        self.dirpath.to_path_buf()
    }

    /// Name of the project (dir name)
    ///
    /// # panics!
    ///
    /// Will panic on empty directory name! (which shouldn't happen?)
    pub fn name(&self) -> String {
        self.dirpath()
            .file_name()
            .unwrap()
            .to_string_lossy()
            .to_string()
    }

    /// Path of the directory of the set the project is a member of.
    ///
    /// DOES NOT "set" the dirpath of the project.
    /// Use [`Self::to_new`] if you want to clone this into a new instance with
    /// a different project name / location.
    pub fn set_dirpath(&self) -> PathBuf {
        self.dirpath().join("..")
    }

    /// Name of the set the project is a member of.
    ///
    /// DOES NOT "set" the name of the project.
    /// Use [`Self::to_new`] if you want to clone this into a new instance with
    /// a different project name / location.
    ///
    /// # panics!
    ///
    /// Will panic on empty set directory name! (which shouldn't happen?)
    pub fn set_name(&self) -> String {
        self.set_dirpath()
            .file_name()
            .unwrap()
            .to_string_lossy()
            .to_string()
    }

    /// Path to the AUDIO pool directory for the set the project is a member of.
    pub fn audio_pool_dirpath(self) -> PathBuf {
        self.set_dirpath().join("AUDIO")
    }

    /// Owned access to sample slot data
    pub fn slots(self) -> Slots {
        self.slots
    }

    /// Reference access to sample slot data
    pub fn slots_ref(&self) -> &Slots {
        &self.slots
    }

    /// Mutable access to sample slot data
    pub fn slots_mut(&mut self) -> &mut Slots {
        &mut self.slots
    }

    /// Create a new instance, mostly with default data, which will write files
    /// to the specified directory path on calling [`Self::save`]
    pub fn new<P: AsRef<Path>>(dirpath: P, state: Option<SavedState>) -> R<Self> {
        let project = ProjDataFile::create(dirpath.as_ref());
        let markers = MarkersDataFile::create(dirpath.as_ref());
        let banks = Banks::new(from_fn(|_| BankFile::default()));
        let arrangements = Arrangements::new(from_fn(|_| ArrangementFile::default()));
        let slots = Slots::default();

        Ok(Self {
            project,
            banks,
            arrangements,
            markers,
            slots,
            dirpath: dirpath.as_ref().to_path_buf(),
            state: state.unwrap_or(SavedState::Working),
        })
    }

    /// Load specific project state for the specified directory.
    /// ```
    /// use ot_tools_io::identifiers::SavedState;
    /// # use ot_tools_interfaces::projects::OctatrackProjectState;
    /// # use ot_tools_interfaces::OtToolsInterfacesError;
    /// # fn main() -> Result<(), OtToolsInterfacesError> {
    /// OctatrackProjectState::load("test-data/blank-project", &SavedState::Working)?;
    /// # Ok(()) }
    /// ```
    pub fn load<P: AsRef<Path>>(dirpath: P, saved_state: &SavedState) -> R<Self> {
        let project = ProjDataFile::load(dirpath.as_ref(), saved_state)?;
        let markers = MarkersDataFile::load(dirpath.as_ref(), saved_state)?;

        let banks = Banks::from_data_files(dirpath.as_ref(), saved_state)?;
        let arrangements = Arrangements::from_data_files(dirpath.as_ref(), saved_state)?;

        let slots = Slots::load(&project, &markers)?;

        Ok(Self {
            project,
            banks,
            arrangements,
            markers,
            slots,
            dirpath: dirpath.as_ref().to_path_buf(),
            state: *saved_state,
        })
    }

    /// Write the current state to the relevant files.
    ///
    /// The `skip_unchanged` flag modifies the save behaviour:
    /// * `true`: only write data files when there have been changes
    /// * `false`: write all data file even if no changes made
    ///
    /// You will usually want to provide `false`.
    /// (There is currently a known bug with serialization / deserialization of
    /// arrangements data in the underlying `ot-tools-io` library crate which
    /// will break your arrangements if you currently set this to `true`).
    ///
    /// Warning: Checksum updates are applied to files when writing. To gain
    /// access to the updated checksum values you will need to read the project
    /// data from the filesystem again.
    ///
    /// ```
    /// use ot_tools_io::identifiers::SavedState;
    /// use ot_tools_interfaces::projects::OctatrackProjectState;
    ///
    /// # use ot_tools_interfaces::OtToolsInterfacesError;
    /// # fn main() -> Result<(), OtToolsInterfacesError> {
    /// let read = OctatrackProjectState::load("test-data/blank-project", &SavedState::Working)?;
    /// std::fs::create_dir_all("/tmp/ot-tools-interfaces/docs/state/save")?;
    /// let mut new = read.to_new("/tmp/ot-tools-interfaces/docs/state/save", None)?;
    /// new.save(true)?;
    /// # Ok(()) }
    /// ```
    pub fn save(&mut self, skip_unchanged: bool) -> R<()> {
        // HACK AROUND THE BORROW CHECKER
        let slots = self.slots.clone();
        slots.write_to_project(self.project_mut());
        slots.write_to_markers(self.markers_mut());
        // END OF HACK AROUND THE BORROW CHECKER

        self.arrangements
            .to_data_files(self.dirpath_ref(), self.state(), skip_unchanged)?;

        self.banks
            .to_data_files(self.dirpath_ref(), self.state(), skip_unchanged)?;

        if skip_unchanged {
            self.project.write_force()?;
            self.markers.write_force()?;
        } else {
            self.project.write()?;
            self.markers.write()?;
        }

        Ok(())
    }

    /// Create a new project in memory (to eventually write as a new project on
    /// filesystem)
    ///
    /// This basically copies the project state, allowing you to move it to a
    /// different location and/or change the saved state.
    ///
    /// # Write [`SavedState::Working`] as [`SavedState::Stored`]
    /// ```
    /// use ot_tools_io::identifiers::SavedState;
    /// use ot_tools_interfaces::projects::OctatrackProjectState;
    ///
    /// # use ot_tools_interfaces::OtToolsInterfacesError;
    /// # fn main() -> Result<(), OtToolsInterfacesError> {
    /// let read = OctatrackProjectState::load("test-data/blank-project", &SavedState::Working)?;
    /// // doesn't write, would need to call save() to do that
    /// let new = read.to_new("test-data/blank-project", Some(SavedState::Stored))?;
    /// assert_eq!(new.state(), &SavedState::Stored);
    /// assert_eq!(new.project_ref().source(), &std::path::PathBuf::from("test-data/blank-project/project.strd"));
    /// # Ok(()) }
    /// ```
    pub fn to_new<P: AsRef<Path>>(&self, new_dirpath: P, new_state: Option<SavedState>) -> R<Self> {
        let state = new_state.unwrap_or(self.state);

        let project = self.project.to_new(new_dirpath.as_ref(), &state);
        let markers = self.markers.to_new(new_dirpath.as_ref(), &state);
        let slots = self.slots.to_new();
        let banks = self.banks.clone();
        let arrangements = self.arrangements.clone();

        Ok(Self {
            project,
            banks,
            arrangements,
            markers,
            slots,
            dirpath: new_dirpath.as_ref().to_path_buf(),
            state,
        })
    }

    /// Inspect and validate that all files are loaded correctly and can be used
    /// by the library
    /// ```
    /// # use ot_tools_io::identifiers::SavedState;
    /// # use ot_tools_interfaces::projects::OctatrackProjectState;
    /// # use ot_tools_interfaces::OtToolsInterfacesError;
    /// # fn main() -> Result<(), OtToolsInterfacesError> {
    /// let read = OctatrackProjectState::load("test-data/blank-project", &SavedState::Working)?;
    /// read.validate()?;
    /// # Ok(()) }
    /// ```
    // TODO: This won't validate _CURRENT_ slots data!
    pub fn validate(&self) -> R<()> {
        let mut is_err = false;
        let project = &self.project.data;

        if !project.check_compatible_os_version()? {
            error!(
                "Unsupported project version! path={:?} supportedVersions={:?}",
                self.project.source, ALLOWED_OS_VERSIONS,
            );
            is_err = true;
        }

        let markers = &self.markers.data;

        if !markers.check_integrity()? {
            error!(
                "Markers file failed integrity check! path={:?}",
                self.markers.source,
            );
            is_err = true;
        }

        // arrangements will always fail validation as checksums have not been
        // implemented for arr_df in self.arrangements.iter() {
        //     let arr = &arr_df.data;
        //     if !arr.check_integrity()? {
        //         eprintln!(
        //             "Arrangement file failed integrity check! path={:?}",
        //             arr_df.source,
        //         );
        //         is_err = true;
        //     }
        // }

        for (idx, bank) in self.banks.iter().enumerate() {
            if !bank.check_integrity()? {
                error!(
                    "Bank file failed integrity check! path={:?}",
                    BankId::try_from(idx)?,
                );
                is_err = true;
            }
        }

        if is_err {
            trace!(
                "`OctatrackProjectState` failed integrity check! path={:?}",
                self.dirpath,
            );
            Err(ProjectsErrors::ValidationFailure.into())
        } else {
            trace!(
                "`OctatrackProjectState` passed integrity check! path={:?}",
                self.dirpath,
            );
            Ok(())
        }
    }
}

#[cfg(test)]
mod test_load_project_saved_state {
    const CRATE_NAME: &str = "ot-tools-interfaces";

    use std::env::temp_dir;
    use std::fs::create_dir_all;
    use std::fs::remove_dir_all;
    use std::path::PathBuf;

    use ot_tools_io::identifiers::BankId;
    use ot_tools_io::identifiers::PartId;
    use ot_tools_io::identifiers::SavedState;
    use ot_tools_io::identifiers::TrackId;

    use crate::projects::OctatrackProjectState;
    use crate::projects::R;
    #[test]
    fn load_default_ok() -> R<()> {
        let dirpath = PathBuf::from("test-data").join("blank-project");
        let r = OctatrackProjectState::load(&dirpath, &SavedState::Working);
        assert!(r.is_ok());
        let valid = r?.validate();
        assert!(valid.is_ok());
        Ok(())
    }

    #[test]
    fn mutate_default_ok() -> R<()> {
        let dirpath = PathBuf::from("test-data").join("blank-project");
        let mut r = OctatrackProjectState::load(&dirpath, &SavedState::Working)?;
        // mutable updates work
        r.banks_mut()[0].checksum = 0;
        assert_eq!(r.banks_ref()[0].checksum, 0);
        Ok(())
    }

    #[test]
    fn write_read_new_ok() -> R<()> {
        let write_dir = temp_dir()
            .join(CRATE_NAME)
            .join("interface")
            .join("write_read_new_ok");

        if !write_dir.exists() {
            create_dir_all(&write_dir)?;
        } else {
            remove_dir_all(&write_dir)?;
            create_dir_all(&write_dir)?;
        };

        println!("written");
        let mut new = OctatrackProjectState::new(&write_dir, Some(SavedState::Working))?;
        new.save(true)?;
        new.clone().validate()?;

        drop(new);

        let new_read = OctatrackProjectState::load(&write_dir, &SavedState::Working)?;
        new_read.clone().validate()?;

        let required_fnames = [
            "bank01.work".to_string(),
            "bank02.work".to_string(),
            "bank03.work".to_string(),
            "bank04.work".to_string(),
            "bank05.work".to_string(),
            "bank06.work".to_string(),
            "bank07.work".to_string(),
            "bank08.work".to_string(),
            "bank09.work".to_string(),
            "bank10.work".to_string(),
            "bank11.work".to_string(),
            "bank12.work".to_string(),
            "bank13.work".to_string(),
            "bank14.work".to_string(),
            "bank15.work".to_string(),
            "bank16.work".to_string(),
            "project.work".to_string(),
            "markers.work".to_string(),
            "arr01.work".to_string(),
            "arr02.work".to_string(),
            "arr03.work".to_string(),
            "arr04.work".to_string(),
            "arr05.work".to_string(),
            "arr06.work".to_string(),
            "arr07.work".to_string(),
            "arr08.work".to_string(),
        ];

        let dirs = std::fs::read_dir(write_dir)?
            .map(|x| x.unwrap())
            .map(|x| x.file_name().to_str().unwrap().to_string())
            .collect::<Vec<_>>();

        let mut missing = Vec::<String>::new();
        for required_fname in required_fnames {
            if !dirs.contains(&required_fname) {
                missing.push(required_fname);
            }
        }

        assert!(missing.is_empty(), "project is missing files: {missing:?}");

        Ok(())
    }

    #[test]
    fn write_read_new_mutable_ok() -> R<()> {
        let write_dir = temp_dir()
            .join(CRATE_NAME)
            .join("interface")
            .join("write_read_new_mutable_ok");

        if !write_dir.exists() {
            create_dir_all(&write_dir)?;
        } else {
            remove_dir_all(&write_dir)?;
            create_dir_all(&write_dir)?;
        };
        let mut new = OctatrackProjectState::new(&write_dir, Some(SavedState::Working))?;
        new.clone().validate()?;

        // do mutable updates work
        for bank_id in BankId::iter() {
            for part_id in PartId::iter() {
                let parts = new
                    .banks_mut()
                    .id_mut(&bank_id)
                    .parts_mut(&SavedState::Working);

                let part = parts.id_mut(&part_id);

                for slots_mut in part.audio_track_machine_slots.iter_mut() {
                    slots_mut.static_slot_id = 100;
                    slots_mut.flex_slot_id = 30;
                }
            }
        }

        new.save(true)?;

        // forcible drop so we don't accidentally re-use it in the test code below!
        drop(new);

        let new_read = OctatrackProjectState::load(&write_dir, &SavedState::Working)?;
        new_read.clone().validate()?;

        // do mutable updates work
        for bank_id in BankId::iter() {
            for part_id in PartId::iter() {
                let part = new_read
                    .banks_ref()
                    .id_ref(&bank_id)
                    .parts_ref(&SavedState::Working)
                    .id_ref(&part_id);

                for (track_idx, slots) in part.audio_track_machine_slots.iter().enumerate() {
                    assert_eq!(
                        slots.static_slot_id,
                        100,
                        "bank {:?} part {:?} track {:?} static slot should be changed in project read from filesystem",
                        bank_id,
                        part_id,
                        TrackId::try_from(track_idx)?,
                    );

                    assert_eq!(
                        slots.flex_slot_id,
                        30,
                        "bank {:?} part {:?} track {:?} flex slot should be changed in project read from filesystem",
                        bank_id,
                        part_id,
                        TrackId::try_from(track_idx)?,
                    );
                }
            }
        }
        Ok(())
    }
}
