#[cfg(test)]
mod test;

use crate::prelude::*;

use super::dir;

use anyhow::{format_err, Context};
use async_fd_lock::{LockRead, LockWrite};
use tokio::fs::{self, File};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

use std::cmp;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

/// All the known local repositories
pub type Projects = BTreeSet<Project>;

#[derive(
    Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
pub struct Project {
    // NOTE: This field must be first to order by most recently closed time
    // descending
    pub last_closed_time: cmp::Reverse<Option<Timestamp>>,
    pub path: PathBuf,
}

#[derive(Debug, Error)]
#[error("Another file-lock is blocking")]
pub struct BlockingLockError;

/// Acquire a read-lock to try to read [`Projects`]. If there's an active
/// write-lock the call will fail.
pub async fn read_projects() -> Result<Projects, ProjectsFileErr> {
    let path = projects_path();
    let path_str = path.to_string_lossy();

    let file = match File::options().create(false).read(true).open(&path).await
    {
        Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {
            // Create a default if file not found
            return Ok(Projects::default());
        }
        result => result
            .with_context(|| format!("Opening projects file ({path_str})"))
            .map_err(OneOf::new)?,
    };
    let mut read_guard = match file.try_lock_read().await {
        Err(err)
            if matches!(err.error.kind(), std::io::ErrorKind::WouldBlock) =>
        {
            return Err(OneOf::new(BlockingLockError));
        }
        result => result
            .map_err(|e| {
                format_err!(
                    "Obtaining projects file read-lock failed with {e:?} ({path_str})"
                )
            })
            .map_err(OneOf::new)?,
    };
    let mut buffer = Vec::new();
    read_guard
        .read_to_end(&mut buffer)
        .await
        .with_context(|| format!("Reading projects file ({path_str})"))
        .map_err(OneOf::new)?;
    let stored: ProjectsStored = toml_edit::de::from_slice(&buffer)
        .with_context(|| format!("Decoding projects file ({path_str})"))
        .map_err(OneOf::new)?;
    Ok(from_stored(stored))
}

pub async fn upsert_project(
    project: Project,
) -> Result<(), (ProjectsFileErr, Project)> {
    let project_clone = project.clone();
    with_projects(move |mut projects| {
        let Project {
            last_closed_time,
            path,
        } = project_clone;
        let last_closed_time = if let Some(ProjectStored {
            last_closed_time: current_last_time,
            ..
        }) = projects.remove(&path)
        {
            cmp::min(last_closed_time, current_last_time)
        } else {
            last_closed_time
        };
        let project = ProjectStored { last_closed_time };
        projects.insert(path, project);
        projects
    })
    .await
    .map_err(|e| (e, project))
}

pub fn updated_closed_time_blocking(
    path: PathBuf,
) -> Result<(), ProjectsFileErr> {
    with_projects_blocking(move |mut projects| {
        let project = ProjectStored {
            last_closed_time: cmp::Reverse(Some(Timestamp::now())),
        };
        projects.insert(path, project);
        projects
    })
}

pub async fn rm_project(path: PathBuf) -> Result<(), ProjectsFileErr> {
    with_projects(move |mut projects| {
        projects.remove(&path);
        projects
    })
    .await
}

type ProjectsStored = BTreeMap<PathBuf, ProjectStored>;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProjectStored {
    last_closed_time: cmp::Reverse<Option<Timestamp>>,
}

fn from_stored(state: ProjectsStored) -> Projects {
    state
        .into_iter()
        .map(|(path, ProjectStored { last_closed_time })| Project {
            last_closed_time,
            path,
        })
        .collect()
}

/// Acquire a lock to try to read, update and write [`Projects`].
async fn with_projects<F: FnOnce(ProjectsStored) -> ProjectsStored>(
    f: F,
) -> Result<(), ProjectsFileErr> {
    let path = projects_path();
    let path_str = path.to_string_lossy();

    fs::create_dir_all(path.parent().unwrap())
        .await
        .with_context(|| {
            format!("Creating projects file store directories ({path_str})")
        })
        .map_err(OneOf::new)?;

    let read_state = async || {
        let file = match File::options()
            .create(false)
            .read(true)
            .open(&path)
            .await
        {
            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {
                // Create a default if file not found
                return Ok(ProjectsStored::default());
            }
            result => result
                .with_context(|| format!("Opening projects file ({path_str})"))
                .map_err(OneOf::new)?,
        };
        let mut read_guard = match file.lock_read().await {
        Err(err)
            if matches!(err.error.kind(), std::io::ErrorKind::WouldBlock) =>
        {
            return Err(OneOf::new(BlockingLockError));
        }
        result => result
            .map_err(|e| {
                format_err!(
                    "Obtaining projects file read-lock failed with {e:?} ({path_str})"
                )
            })
            .map_err(OneOf::new)?,
    };
        let mut buffer = Vec::new();
        read_guard
            .read_to_end(&mut buffer)
            .await
            .with_context(|| format!("Reading projects file ({path_str})"))
            .map_err(OneOf::new)?;
        let stored: ProjectsStored = toml_edit::de::from_slice(&buffer)
            .with_context(|| format!("Decoding projects file ({path_str})"))
            .map_err(OneOf::new)?;
        Ok(stored)
    };
    let state = read_state().await?;

    let file = File::options()
        .create(true)
        .write(true)
        .truncate(true)
        .open(&path)
        .await
        .with_context(|| format!("Opening projects file ({path_str})"))
        .map_err(OneOf::new)?;
    let mut write_guard = match file.lock_write().await {
        Err(err)
            if matches!(err.error.kind(), std::io::ErrorKind::WouldBlock) =>
        {
            return Err(OneOf::new(BlockingLockError));
        }
        result => result
            .map_err(|e| {
                format_err!(
                    "Obtaining projects file write-lock failed with {e:?} ({path_str})"
                )
            })
            .map_err(OneOf::new)?,
    };

    let state = f(state);

    let state_bytes = toml_edit::ser::to_string_pretty(&state)
        .with_context(|| format!("Encoding projects file ({path_str})"))
        .map_err(OneOf::new)?;
    write_guard
        .write_all(state_bytes.as_bytes())
        .await
        .with_context(|| format!("Writing projects file ({path_str})"))
        .map_err(OneOf::new)
}

/// Acquire a write-lock to try to read, update and write [`Projects`]. If
/// there's an active write-lock the call will fail with a
/// [`BlockingLockError`].
fn with_projects_blocking<F: FnOnce(ProjectsStored) -> ProjectsStored>(
    f: F,
) -> Result<(), ProjectsFileErr> {
    use async_fd_lock::blocking::{LockRead, LockWrite};
    use std::fs;
    use std::io::{Read, Write};

    let path = projects_path();
    let path_str = path.to_string_lossy();

    fs::create_dir_all(path.parent().unwrap())
        .with_context(|| {
            format!("Creating projects file store directories ({path_str})")
        })
        .map_err(OneOf::new)?;

    let read_state = || {
        let file = match fs::File::options()
            .create(false)
            .read(true)
            .open(&path)
        {
            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {
                // Create a default if file not found
                return Ok(ProjectsStored::default());
            }
            result => result
                .with_context(|| format!("Opening projects file ({path_str})"))
                .map_err(OneOf::new)?,
        };
        let mut read_guard = match file.lock_read() {
        Err(err)
            if matches!(err.error.kind(), std::io::ErrorKind::WouldBlock) =>
        {
            return Err(OneOf::new(BlockingLockError));
        }
        result => result
            .map_err(|e| {
                format_err!(
                    "Obtaining projects file read-lock failed with {e:?} ({path_str})"
                )
            })
            .map_err(OneOf::new)?,
    };
        let mut buffer = Vec::new();
        read_guard
            .read_to_end(&mut buffer)
            .with_context(|| format!("Reading projects file ({path_str})"))
            .map_err(OneOf::new)?;
        let stored: ProjectsStored = toml_edit::de::from_slice(&buffer)
            .with_context(|| format!("Decoding projects file ({path_str})"))
            .map_err(OneOf::new)?;
        Ok(stored)
    };
    let state = read_state()?;

    let file = fs::File::options()
        .create(true)
        .write(true)
        .truncate(true)
        .open(&path)
        .with_context(|| format!("Opening projects file ({path_str})"))
        .map_err(OneOf::new)?;
    let mut write_guard = match file.lock_write() {
        Err(err)
            if matches!(err.error.kind(), std::io::ErrorKind::WouldBlock) =>
        {
            return Err(OneOf::new(BlockingLockError));
        }
        result => result
            .map_err(|e| {
                format_err!(
                    "Obtaining projects file write-lock failed with {e:?} ({path_str})"
                )
            })
            .map_err(OneOf::new)?,
    };

    let state = f(state);

    let state_bytes = toml_edit::ser::to_string_pretty(&state)
        .with_context(|| format!("Encoding projects file ({path_str})"))
        .map_err(OneOf::new)?;
    write_guard
        .write_all(state_bytes.as_bytes())
        .with_context(|| format!("Writing projects file ({path_str})"))
        .map_err(OneOf::new)
}

pub type ProjectsFileErr = OneOf<(BlockingLockError, anyhow::Error)>;

fn projects_path() -> PathBuf {
    PathBuf::from_iter([dir::data().as_path(), Path::new(PROJECTS_FILE)])
}

const PROJECTS_FILE: &str = "projects.toml";