Experimental, rust-based pijul editor support
#![feature(map_try_insert)]

#[macro_use]
extern crate napi_derive;

use std::path::{Path, PathBuf};

use anyhow::bail;
use canonical_path::{CanonicalPath, CanonicalPathBuf};
use libpijul::change::{BaseHunk, Change};
use libpijul::changestore::ChangeStore;
use libpijul::vertex_buffer::VertexBuffer;
use libpijul::{
    change::{ChangeHeader, LocalChange},
    pristine::TxnT,
    MutTxnT, RecordBuilder,
};
use libpijul::{Base32, ChangeId, GraphTxnT, Hash, MutTxnTExt, TxnTExt};
use pijul_repository::Repository;
use walkdir::WalkDir;

#[derive(Debug, Eq, PartialEq, Hash, Clone)]
pub enum Annotation {
    Modified,
}

#[derive(Debug, Eq, PartialEq, Hash, Clone)]
pub enum State {
    Tracked(Vec<Annotation>),
    Added,
    Untracked,
    Ignored,
}

impl State {
    fn badge(&self) -> String {
        match self {
            Self::Tracked(annotations) => annotations
                .iter()
                .map(|annotation| {
                    match annotation {
                        Annotation::Modified => "M",
                    }
                    .to_string()
                })
                .collect::<Vec<String>>()
                .join(", "),
            Self::Added => String::from("A"),
            Self::Untracked => String::from("U"),
            Self::Ignored => String::new(),
        }
    }

    fn tooltip(&self) -> String {
        match self {
            Self::Tracked(annotations) => annotations
                .iter()
                .map(|annotation| {
                    match annotation {
                        Annotation::Modified => "Modified",
                    }
                    .to_string()
                })
                .collect::<Vec<String>>()
                .join(", "),
            Self::Added => String::from("Added"),
            Self::Untracked => String::from("Untracked"),
            Self::Ignored => String::new(),
        }
    }
}

#[napi]
pub fn resolve_repositories(paths: Vec<String>) -> Result<Vec<String>, napi::Error> {
    let mut repositories = Vec::new();

    for path in paths {
        let path_buf = PathBuf::from(path);
        let potential_root = Repository::find_root(Some(path_buf));

        if let Ok(root) = potential_root {
            let path_str = root.path.to_string_lossy().to_string();
            // Make sure to only insert unique repositories
            if repositories.iter().all(|repo_path| repo_path != &path_str) {
                repositories.push(path_str);
            }
        }
    }

    Ok(repositories)
}

// TODO: ignored should include untracked files
#[napi]
pub fn ignored(path: String) -> Result<bool, anyhow::Error> {
    let root = find_closest_root(path.clone())?;
    let root = CanonicalPath::new(&root).unwrap();
    let path = CanonicalPath::new(Path::new(&path)).unwrap();

    Ok(!libpijul::working_copy::filesystem::filter_ignore(
        root,
        path,
        root.join(path)?.is_dir(),
    ))
}

#[napi]
fn find_closest_root(path: String) -> Result<String, anyhow::Error> {
    let mut path = PathBuf::from(path).join(libpijul::DOT_DIR);
    while let Some(parent) = path.parent() {
        if parent.join(libpijul::DOT_DIR).is_dir() {
            return Ok(parent.to_string_lossy().to_string());
        }

        path.pop();
    }

    bail!("No path found");
}

pub struct InlineCreditBuffer {
    hashes: Vec<(ChangeId, u64)>,
}

impl InlineCreditBuffer {
    pub fn new() -> Result<Self, anyhow::Error> {
        Ok(Self { hashes: Vec::new() })
    }
}

impl VertexBuffer for InlineCreditBuffer {
    fn output_line<E, F>(
        &mut self,
        key: libpijul::Vertex<libpijul::ChangeId>,
        contents_writer: F,
    ) -> Result<(), E>
    where
        E: From<std::io::Error>,
        F: FnOnce(&mut [u8]) -> Result<(), E>,
    {
        // Do not render the root change
        if key.change.is_root() {
            return Ok(());
        }

        let mut buf = vec![0; key.end - key.start];
        contents_writer(&mut buf)?;

        // TODO: this will not work with alternative encodings
        for _line in String::from_utf8(buf).expect("Non-utf8 change").lines() {
            if let Some((last_change, lines)) = self.hashes.last_mut() {
                if last_change.to_owned() == key.change {
                    *lines += 1;
                    continue;
                }
            }

            // Fell through to end, so must be a new change for this line
            self.hashes.push((key.change, 1));
        }

        Ok(())
    }

    fn output_conflict_marker<C: libpijul::changestore::ChangeStore>(
        &mut self,
        _s: &str,
        _id: usize,
        _sides: Option<(&C, &[&Hash])>,
    ) -> Result<(), std::io::Error> {
        unimplemented!();
    }
}

// TODO: rename this to something better
#[napi]
pub struct FileState {
    path: PathBuf,
    current_state: Change,
    recorded_changes: Vec<Change>,
    hashes: Vec<(Hash, u64)>,
}

#[napi]
impl FileState {
    #[napi(constructor)]
    pub fn new(path: String) -> Result<Self, anyhow::Error> {
        let path = PathBuf::from(path);
        let repo = Repository::find_root(Some(path.to_owned())).expect("Could not find root");
        let txn = repo.pristine.arc_txn_begin()?;
        let channel_name = txn
            .read()
            .current_channel()
            .unwrap_or(libpijul::DEFAULT_CHANNEL)
            .to_string();

        let channel = txn.write().open_or_create_channel(&channel_name)?;
        let prefix = path.strip_prefix(&repo.path)?.to_string_lossy().to_string();
        let mut record_builder = RecordBuilder::new();
        record_builder.record(
            txn.clone(),
            libpijul::Algorithm::default(),
            false,
            &libpijul::DEFAULT_SEPARATOR,
            channel.clone(),
            &repo.working_copy,
            &repo.changes,
            &prefix,
            std::thread::available_parallelism()?.into(),
        )?;
        let recorded = record_builder.finish();
        let mut write_txn = txn.write();
        let actions: Vec<_> = recorded
            .actions
            .into_iter()
            .map(|rec| rec.globalize(&*write_txn).unwrap())
            .collect();
        let contents = if let Ok(cont) = std::sync::Arc::try_unwrap(recorded.contents) {
            cont.into_inner()
        } else {
            unreachable!()
        };
        let mut change = LocalChange::make_change(
            &*write_txn,
            &channel,
            actions,
            contents,
            ChangeHeader::default(),
            Vec::new(),
        )?;

        let (oldest_change, _ambiguous) =
            write_txn.follow_oldest_path(&repo.changes, &channel, prefix.as_str())?;

        repo.changes
            .save_change(&mut change, |_, _| Ok::<_, anyhow::Error>(()))?;
        write_txn.apply_change(&repo.changes, &mut channel.write(), &change.hash()?)?;

        // Drop writeable access to prevent ownership deadlock from calling txn.read()
        std::mem::drop(write_txn);

        let mut credit_buffer = InlineCreditBuffer::new()?;
        libpijul::output::output_file(
            &repo.changes,
            &txn,
            &channel,
            oldest_change,
            &mut credit_buffer,
        )?;

        let mut hashes = Vec::new();
        let mut changes: Vec<Change> = Vec::new();
        for (change_id, lines) in credit_buffer.hashes {
            let hash = Hash::from(txn.read().get_external(&change_id)?.unwrap().to_owned());
            let change = repo.changes.get_change(&hash)?;
            if !changes.iter().any(|c| c.hash().unwrap() == hash) {
                changes.push(change);
            }
            hashes.push((hash, lines));
        }

        // Make sure to not pollute .pijul/changes
        repo.changes.del_change(&change.hash()?)?;

        Ok(Self {
            path,
            current_state: change,
            recorded_changes: changes,
            hashes,
        })
    }

    // TODO: State::Ignored
    fn state(&self) -> State {
        let mut annotations = Vec::new();
        for change in &self.current_state.changes {
            match change {
                BaseHunk::Edit { .. } | BaseHunk::Replacement { .. } => {
                    annotations.push(Annotation::Modified);
                }
                BaseHunk::FileAdd { .. } => {
                    return State::Added;
                }
                _ => todo!(),
            }
        }

        State::Tracked(annotations)
    }

    #[napi]
    pub fn badge(&self) -> String {
        self.state().badge()
    }

    #[napi]
    pub fn tooltip(&self) -> String {
        self.state().tooltip()
    }

    // TODO: obviously this is bad
    #[napi]
    pub fn untracked(&self) -> bool {
        false
    }

    #[napi]
    pub fn path(&self) -> String {
        self.path.to_string_lossy().to_string()
    }

    fn change_at<'a>(&'a self, target_line: u32) -> Result<&'a Change, anyhow::Error> {
        let mut current_line: u64 = 0;
        for (hash, line_count) in &self.hashes {
            if (current_line..current_line + line_count).contains(&(target_line as u64)) {
                if hash == &self.current_state.hash()? {
                    // Hash matches current state
                    return Ok(&self.current_state);
                } else {
                    for change in &self.recorded_changes {
                        if &change.hash()? == hash {
                            return Ok(&change);
                        }
                    }
                }
            }

            current_line += line_count;
        }

        bail!("Line outside of valid range");
    }

    #[napi]
    pub fn hash_at(&self, target_line: u32) -> Result<String, anyhow::Error> {
        let change = self.change_at(target_line)?;
        Ok(change.hash()?.to_base32())
    }

    #[napi]
    pub fn authors_at(&self, target_line: u32) -> Result<String, anyhow::Error> {
        let change = self.change_at(target_line)?;
        if change == &self.current_state {
            Ok(String::from("You"))
        } else {
            Ok(String::from("You"))
        }
    }

    #[napi]
    pub fn message_at(&self, target_line: u32) -> Result<String, anyhow::Error> {
        let change = self.change_at(target_line)?;
        if change == &self.current_state {
            Ok(String::from("Unrecorded changes"))
        } else {
            Ok(change.header.message.clone())
        }
    }
}

#[napi]
pub fn all_files(path_name: String) -> Result<Vec<FileState>, anyhow::Error> {
    let path = PathBuf::from(&path_name);
    println!("Path: {path:?}");

    if path.is_file() {
        return Ok(vec![FileState::new(path_name)?]);
    }

    let root = match Repository::find_root(Some(path)) {
        Ok(repo) => CanonicalPathBuf::new(repo.path)?,
        Err(_) => return Ok(Vec::new()),
    };

    let mut children = Vec::new();
    let walker = WalkDir::new(&root).into_iter();
    for entry in walker.filter_entry(|entry| {
        let current = CanonicalPathBuf::new(entry.path()).unwrap();
        libpijul::working_copy::filesystem::filter_ignore(
            root.as_canonical_path(),
            current.as_canonical_path(),
            current.is_dir(),
        )
    }) {
        let path = entry?.path().to_path_buf();
        if path.is_file() {
            let name = path.to_string_lossy().to_string();
            println!("Creating file state with name: {name}");
            children.push(FileState::new(name)?);
        }
    }

    Ok(children)
}