#![feature(map_try_insert)]

// TODO: cache wherever possible
// TODO: audit pub fields
// TODO: changestore/pristine FileSystem integration should integrate with WorkingCopy(Read) traits
// TODO: test against memfs to make sure alternative filesystems are supported https://github.com/microsoft/vscode-extension-samples/blob/main/fsprovider-sample/README.md
// TODO: zero-copy data if possible?
// TODO: try do one transaction per function call (no inline .read() etc) - unless maybe if only one read/write call?
// TODO: atomic updates to everything? e.g. write working copy and pristine in one go so everything stays in sync
// TODO: inject like 10secs of latency everywhere to make sure cancellation works properly (same for any await point - could introduce TOCTOU bugs)
// TODO: update tracked state when externally modified on disk (e.g. pijul command directly) - and figure out how to test this?
// TODO: make sure paths are based in the root of the repo

use std::borrow::Cow;

use camino::{Utf8Path, Utf8PathBuf};
use libpijul::change::{Atom, BaseHunk, Change, ChangeHeader, TextSerError};
use libpijul::changestore::ChangeStore;
use libpijul::pristine::TxnErr;
use libpijul::pristine::sanakirja::{MutTxn, Pristine, SanakirjaError};
use libpijul::working_copy::WorkingCopy;
use libpijul::working_copy::{self, WorkingCopyRead};
use libpijul::{ArcTxn, ChangeId, ChannelRef, GraphTxnT, TxnT, Vertex};
use libpijul::{TxnTExt, changestore};

use crate::author::{AuthorSource, Authors, GetAuthorsError};
use crate::file_system::changes::{
    ActiveHunk, CreditSource, FileCredits, FileCreditsError, HunkDiff,
};
use crate::file_system::open_file::contents::FileContents;
use crate::file_system::open_file::{OpenFile, OpenFileError};
use crate::file_system::working_copy::{
    EditorWorkingCopy, UpdateOpenFileContentsError, UpdateOpenFileCreditsError,
};
use crate::path_state::{PathState, PathStates, PathStatesError};

pub mod author;
pub mod file_system;
pub mod path_state;

#[derive(Debug, thiserror::Error)]
pub enum RepositoryError<C: std::error::Error + 'static, W: std::error::Error + 'static> {
    #[error("unable to open pristine: {0:#?}")]
    Pristine(#[from] SanakirjaError),
    #[error("unable to begin transaction: {0:#?}")]
    BeginTransaction(#[from] BeginTransactionError),
    #[error("unable to get untracked paths: {0:#?}")]
    Changes(#[from] GetAuthorsError<C>),
    #[error("unable to get path states: {0:#?}")]
    PathStates(#[from] PathStatesError<C, W>),
}

#[derive(Debug, thiserror::Error)]
pub enum BeginTransactionError {
    #[error("unable to begin transaction: {0:#?}")]
    Transaction(#[from] SanakirjaError),
    #[error("unable to open channel: {0:#?}")]
    Channel(#[from] TxnErr<SanakirjaError>),
}

#[derive(Debug, thiserror::Error)]
pub enum GetChangeError<C: std::error::Error + 'static> {
    #[error("Unable to begin transaction: {0}")]
    Transaction(#[from] SanakirjaError),
    #[error("Unable to get external hash: {0}")]
    Hash(#[from] TxnErr<SanakirjaError>),
    #[error("Failed to get change: {0}")]
    Change(C),
    #[error("No matching change for {change_id:?}")]
    NoMatchingChange { change_id: ChangeId },
}

#[derive(Debug, thiserror::Error)]
pub enum FindVertexHunkError<C: std::error::Error + 'static> {
    #[error("Unable to get change: {0}")]
    Change(#[from] GetChangeError<C>),
    #[error("No hunk found for vertex: {vertex:?}")]
    NoHunkForVertex { vertex: Vertex<ChangeId> },
}

#[derive(Debug, thiserror::Error)]
pub enum CreditError {
    #[error("error while checking if `{path}` is tracked: {error:#?}")]
    GetIsTracked {
        path: Utf8PathBuf,
        error: SanakirjaError,
    },
    #[error("missing open file for {0}")]
    MissingOpenFile(Utf8PathBuf),
}

#[derive(Debug, thiserror::Error)]
pub enum UpdatePathStateError<C: std::error::Error + 'static, W: std::error::Error + 'static> {
    #[error("Unable to begin transaction: {0}")]
    BeginTransaction(#[from] BeginTransactionError),
    #[error(transparent)]
    PathStates(#[from] PathStatesError<C, W>),
}

#[derive(Debug, thiserror::Error)]
pub enum ActiveHunkError<C: std::error::Error + 'static> {
    #[error("File is not tracked by Pijul")]
    Untracked,
    #[error("Missing file contents for hunk: {hunk:#?}")]
    MissingFileContents {
        hunk: Box<BaseHunk<Atom<Option<libpijul::Hash>>, libpijul::change::Local>>,
    },
    #[error("Failed to get change for {change_id:?}: {error}")]
    Change {
        change_id: ChangeId,
        error: GetChangeError<C>,
    },
    #[error("Unable to find hunk for vertex: {0}")]
    FindVertexHunk(#[from] FindVertexHunkError<C>),
    #[error("Failed to get change contents: {0}")]
    ChangeContents(#[from] TextSerError<C>),
    #[error("Hunk not yet implemented: {hunk:#?}")]
    UnimplementedHunk {
        hunk: Box<BaseHunk<Atom<Option<libpijul::Hash>>, libpijul::change::Local>>,
    },
}

#[derive(Debug, thiserror::Error)]
pub enum CreateOpenFileError<C: std::error::Error + 'static, W: std::error::Error + 'static> {
    #[error("unable to get metadata for file: {0:?}")]
    FileMetadata(W),
    #[error(transparent)]
    OpenFile(#[from] OpenFileError<C, W>),
    #[error("File already open at {0}")]
    AlreadyOpen(Utf8PathBuf),
    #[error(transparent)]
    FileCredits(#[from] FileCreditsError<C, W>),
}

#[derive(Debug, thiserror::Error)]
pub enum UpdateOpenFileError<C: std::error::Error + 'static, W: std::error::Error + 'static> {
    #[error("Unable to begin transaction: {0}")]
    BeginTransaction(#[from] BeginTransactionError),
    #[error(transparent)]
    UpdateContents(#[from] UpdateOpenFileContentsError),
    #[error(transparent)]
    UpdateCredits(#[from] UpdateOpenFileCreditsError<C, W>),
}

pub type FileSystemRepository =
    Repository<changestore::filesystem::FileSystem, working_copy::FileSystem>;

pub struct Repository<C, W>
where
    C: ChangeStore + Clone + Send + 'static,
    W: WorkingCopy + Clone + Send + Sync + 'static,
{
    pub authors: Authors,
    pub working_copy: EditorWorkingCopy<W>,
    pub path_states: PathStates,
    // TODO: LRU cache for recent changes
    pub change_store: C,
    pub pristine: Pristine,
}

impl FileSystemRepository {
    pub fn new(
        root: &Utf8Path,
        // TODO: take open files here
    ) -> Result<Self, RepositoryError<changestore::filesystem::Error, std::io::Error>> {
        let dot_directory = root.join(libpijul::DOT_DIR);

        let change_store = changestore::filesystem::FileSystem::from_root(root.as_str(), 256);
        let pristine = Pristine::new(dot_directory.join("pristine").join("db").as_str())?;
        let file_system = working_copy::FileSystem::from_root(root.as_str());

        let (transaction, channel) = begin_transaction(&pristine)?;
        let authors = Authors::new(&dot_directory, &transaction, &channel, &change_store)?;

        let path_states =
            PathStates::new(root, &transaction, &channel, &file_system, &change_store)?;

        let working_copy = EditorWorkingCopy::new(file_system);

        Ok(Self {
            authors,
            working_copy,
            path_states,
            change_store,
            pristine,
        })
    }
}

impl<C, W> Repository<C, W>
where
    C: ChangeStore + Clone + Send + 'static,
    W: WorkingCopy + Clone + Send + Sync + 'static,
{
    pub fn get_change(&self, change_id: ChangeId) -> Result<Change, GetChangeError<C::Error>> {
        let transaction = self.pristine.arc_txn_begin()?;
        let read_transaction = transaction.read();

        let external_hash = read_transaction
            .get_external(&change_id)?
            .ok_or(GetChangeError::NoMatchingChange { change_id })?;

        self.change_store
            .get_change(&external_hash.into())
            .map_err(GetChangeError::Change)
    }

    pub fn find_vertex_hunk(
        &self,
        vertex: Vertex<ChangeId>,
    ) -> Result<
        (
            usize,
            BaseHunk<Atom<Option<libpijul::Hash>>, libpijul::change::Local>,
        ),
        FindVertexHunkError<C::Error>,
    > {
        let change = self.get_change(vertex.change)?;

        change
            .hashed
            .changes
            .into_iter()
            .enumerate()
            .find_map(|(index, hunk)| {
                hunk.iter()
                    .any(|atom| match atom {
                        Atom::NewVertex(new_vertex) => {
                            // Changes that depend on this one may later fragment the original contents,
                            // so the current vertex may be a subset of the original contents.
                            new_vertex.start <= vertex.start && new_vertex.end >= vertex.end
                        }
                        Atom::EdgeMap(_edge_map) => false,
                    })
                    // Hunks are 1-indexed
                    .then_some((index + 1, hunk))
            })
            .ok_or(FindVertexHunkError::NoHunkForVertex { vertex })
    }

    pub fn get_active_hunk(
        &self,
        open_file: &OpenFile,
        credit_source: CreditSource,
    ) -> Result<ActiveHunk, ActiveHunkError<C::Error>> {
        let Some(credits) = open_file.credits.as_ref() else {
            return Err(ActiveHunkError::Untracked);
        };

        let change_contents = match credit_source {
            CreditSource::Tracked { vertex } => {
                let change =
                    self.get_change(vertex.change)
                        .map_err(|error| ActiveHunkError::Change {
                            change_id: vertex.change,
                            error,
                        })?;

                Cow::Owned(change.contents)
            }
            CreditSource::Untracked { hunk_index: _ } => {
                Cow::Borrowed(&credits.unrecorded_state.change_contents)
            }
        };

        let (hunk_index, hunk) = match credit_source {
            CreditSource::Tracked { vertex } => {
                let (index, hunk) = self.find_vertex_hunk(vertex)?;
                (index, Cow::Owned(hunk))
            }
            CreditSource::Untracked { hunk_index } => {
                // Return 1-indexed hunks
                (
                    hunk_index + 1,
                    Cow::Borrowed(&credits.unrecorded_state.hunks[hunk_index]),
                )
            }
        };

        // TODO: properly handle encoding
        let hunk_diff = match hunk.as_ref() {
            BaseHunk::FileAdd {
                contents,
                add_name: _,
                add_inode: _,
                path: _,
                encoding,
            } => {
                let Some(contents) = contents else {
                    return Err(ActiveHunkError::MissingFileContents {
                        hunk: Box::new(hunk.into_owned()),
                    });
                };

                let file_contents = libpijul::change::get_change_contents(
                    &self.change_store,
                    contents,
                    &change_contents,
                )?;
                let file_text = String::from_utf8(file_contents).unwrap();

                HunkDiff::TextChange {
                    lines_added: file_text.lines().map(str::to_string).collect(),
                    lines_removed: Vec::new(),
                }
            }
            BaseHunk::Replacement {
                change,
                replacement,
                local: _,
                encoding,
            } => {
                let old_contents = libpijul::change::get_change_contents(
                    &self.change_store,
                    change,
                    &change_contents,
                )?;
                let new_contents = libpijul::change::get_change_contents(
                    &self.change_store,
                    replacement,
                    &change_contents,
                )?;

                let old_text = String::from_utf8(old_contents).unwrap();
                let new_text = String::from_utf8(new_contents).unwrap();

                HunkDiff::TextChange {
                    lines_added: new_text.lines().map(str::to_string).collect(),
                    lines_removed: old_text.lines().map(str::to_string).collect(),
                }
            }
            BaseHunk::Edit {
                change,
                local: _,
                encoding,
            } => {
                let new_contents = libpijul::change::get_change_contents(
                    &self.change_store,
                    change,
                    &change_contents,
                )?;

                let text = String::from_utf8(new_contents).unwrap();

                if let Atom::EdgeMap(edge) = change
                    && (edge.edges.is_empty() || edge.edges[0].flag.is_deleted())
                {
                    HunkDiff::TextChange {
                        lines_added: Vec::new(),
                        lines_removed: text.lines().map(str::to_string).collect(),
                    }
                } else {
                    HunkDiff::TextChange {
                        lines_added: text.lines().map(str::to_string).collect(),
                        lines_removed: Vec::new(),
                    }
                }
            }
            _ => {
                return Err(ActiveHunkError::UnimplementedHunk {
                    hunk: Box::new(hunk.into_owned()),
                });
            }
        };

        Ok(ActiveHunk {
            index: hunk_index,
            diff: hunk_diff,
        })
    }

    pub fn get_path_state(&self, path: &Utf8Path) -> Option<PathState> {
        self.path_states.get_path_state(path)
    }

    pub fn iter_path_states(&self) -> impl Iterator<Item = (Utf8PathBuf, PathState)> {
        self.path_states.iter_path_states()
    }

    pub fn update_path_state(
        &mut self,
        path: Utf8PathBuf,
    ) -> Result<(), UpdatePathStateError<C::Error, W::Error>> {
        let (transaction, channel) = begin_transaction(&self.pristine)?;

        self.path_states.update_path_state(
            path,
            &transaction,
            &channel,
            &self.working_copy.working_copy,
            &self.change_store,
        )?;

        Ok(())
    }

    pub fn authors_for_change(&self, change_header: &ChangeHeader) -> Vec<&AuthorSource> {
        self.authors.authors_for_change(change_header)
    }

    pub fn create_open_file(
        &mut self,
        path: Utf8PathBuf,
        file_contents: String,
    ) -> Result<(), CreateOpenFileError<C::Error, W::Error>> {
        let metadata = self
            .working_copy
            .file_metadata(path.as_str())
            .map_err(CreateOpenFileError::FileMetadata)?;

        let (transaction, channel) = begin_transaction(&self.pristine).unwrap();

        let open_file = OpenFile {
            contents: FileContents::new(path.clone(), metadata, file_contents, &self.working_copy),
            credits: None,
        };

        self.working_copy
            .open_files
            .try_insert(path.clone(), open_file)
            .map_err(|_error| CreateOpenFileError::AlreadyOpen(path.clone()))?;

        let is_tracked = transaction
            .read()
            .is_tracked(path.as_str())
            .map_err(|error| OpenFileError::CheckIfTracked {
                path: path.clone(),
                error,
            })?;

        // Generate the credits if applicable
        // Since `FileCredits::new()` reads the file contents from the working copy,
        // it must be done _after_ inserting the `FileContents` first.
        let file_credits = match is_tracked {
            true => Some(FileCredits::new(
                &path,
                &self.working_copy.open_files.get(&path).unwrap().contents,
                &transaction,
                &channel,
                &self.change_store,
                &self.working_copy,
            )?),
            false => None,
        };

        self.working_copy.open_files.get_mut(&path).unwrap().credits = file_credits;

        Ok(())
    }

    pub fn update_open_file(
        &mut self,
        path: &Utf8Path,
        character_offset: usize,
        characters_replaced: usize,
        replacement_text: &str,
    ) -> Result<(), UpdateOpenFileError<C::Error, W::Error>> {
        let (transaction, channel) = begin_transaction(&self.pristine)?;

        // Since the credits depend on file contents already stored in the working copy,
        // update its contents _before_ updating the credits.
        self.working_copy.update_open_file_contents(
            path,
            character_offset,
            characters_replaced,
            replacement_text,
        )?;

        self.working_copy.update_open_file_credits(
            path,
            &transaction,
            &channel,
            &self.change_store,
        )?;

        Ok(())
    }

    pub fn get_open_file(&self, path: &Utf8Path) -> Option<&OpenFile> {
        self.working_copy.open_files.get(path)
    }
}

fn begin_transaction(
    pristine: &Pristine,
) -> Result<(ArcTxn<MutTxn<()>>, ChannelRef<MutTxn<()>>), BeginTransactionError> {
    let transaction = pristine.arc_txn_begin()?;
    let channel = transaction
        .read()
        .load_channel(
            transaction
                .read()
                .current_channel()
                .unwrap_or(libpijul::DEFAULT_CHANNEL),
        )?
        .unwrap();

    Ok((transaction, channel))
}