// SPDX-FileCopyrightText: 2024 blinry <mail@blinry.org>
// SPDX-FileCopyrightText: 2024 zormit <nt4u@kpvn.de>
// SPDX-FileCopyrightText: 2026 Caleb Maclennan <caleb@alerque.com>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use std::borrow::Cow;
use std::collections::HashMap;

use anyhow::bail;
use anyhow::{Context, Result};
use automerge::{
    AutoCommit, ChangeHash, ObjId, ObjType, Patch, PatchLog, ReadDoc, ScalarValue, Value,
    sync::{Message as AutomergeSyncMessage, State as AutomergeSyncState, SyncDoc},
    transaction::Transactable,
};
use dissimilar::Chunk;
use tracing::{debug, info};

use crate::{
    path::RelativePath,
    types::{EditorTextDelta, PatchEffect, TextDelta},
};

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Content {
    String(String),
    Bytes(Vec<u8>),
}

/// Encapsulates the Automerge `AutoCommit` and provides a generic interface,
/// s.t. we don't need to worry about automerge internals elsewhere.
///
/// The interface allows us to apply changes to the CRDT in two ways:
/// - synchronization with other CRDTs through sync messages
/// - applying a delta (coming from an editor) directly
///
/// Furthermore, there's a way to retrieve and initialize the content.
#[derive(Debug)]
#[must_use]
pub struct Document {
    files: HashMap<RelativePath, Content>,
    doc: AutoCommit,
}

impl Default for Document {
    fn default() -> Self {
        // We're baking a single static initial state into the binary rather than generating new documents
        // from a random seed in each client so that documents created by independent peers can be merged.
        // See build.rs for how to force a rebuild of this initial state, but be aware updating the initial
        // state is a breaking change that will make Teamtype peers incompatible with each other.
        // See https://automerge.org/docs/cookbook/modeling-data/#setting-up-an-initial-document-structure
        let initial_automerge_doc_bytes = include_bytes!("initial_automerge_doc.bin");
        Self::load(initial_automerge_doc_bytes)
    }
}

impl Document {
    // TODO: use sth like try_load + Result to bubble up potential errors.
    pub fn load(bytes: &[u8]) -> Self {
        let doc =
            AutoCommit::load(bytes).expect("Failed to load Automerge document from given bytes");
        let mut result = Self {
            files: HashMap::default(),
            doc,
        };
        result.update_files();
        result
    }

    #[must_use]
    pub fn save(&mut self) -> Vec<u8> {
        self.doc.save()
    }

    #[must_use]
    pub fn save_incremental(&mut self) -> Vec<u8> {
        self.doc.save_incremental()
    }

    #[must_use]
    pub fn actor_id(&self) -> String {
        self.doc.get_actor().to_hex_string()
    }

    #[must_use]
    pub fn receive_sync_message_log_patches(
        &mut self,
        message: AutomergeSyncMessage,
        peer_state: &mut AutomergeSyncState,
    ) -> Vec<Patch> {
        let mut patch_log = PatchLog::active();
        self.doc
            .sync()
            .receive_sync_message_log_patches(peer_state, message, &mut patch_log)
            .expect("Failed to apply sync message to Automerge document");
        let patches = self.doc.make_patches(&mut patch_log);

        // Update files cache.
        for patch in &patches {
            match PatchEffect::try_from(patch).expect("Should be able to convert to patch effect") {
                PatchEffect::FileChange(file_text_delta) => {
                    let old_text = if let Some(Content::String(old_text)) =
                        self.files.get(&file_text_delta.file_path)
                    {
                        old_text
                    } else {
                        // File does not exist yet, create an empty one.
                        self.files.insert(
                            file_text_delta.file_path.clone(),
                            Content::String(String::new()),
                        );
                        // And set old_text to the empty string.
                        ""
                    };
                    // We're not in a good position here to "reject" the sync message, so let's
                    // panic on errors.
                    let new_text = file_text_delta.delta.apply_to(old_text).expect("If Automerge and our file cache work correctly, this delta should always apply");
                    self.files
                        .insert(file_text_delta.file_path, Content::String(new_text));
                }
                PatchEffect::FileRemoval(path) => {
                    self.files.remove(&path);
                }
                PatchEffect::FileBytes(path, bytes) => {
                    self.files.insert(path, Content::Bytes(bytes));
                }
                PatchEffect::NoEffect => {
                    // Nothing to do.
                }
            }
        }

        patches
    }

    #[must_use]
    pub fn generate_sync_message(
        &mut self,
        peer_state: &mut AutomergeSyncState,
    ) -> Option<AutomergeSyncMessage> {
        self.doc.sync().generate_sync_message(peer_state)
    }

    pub fn apply_delta_to_doc(
        &mut self,
        delta: &TextDelta,
        file_path: &RelativePath,
    ) -> Result<()> {
        // Update Automerge document.
        let text_obj = self
            .text_obj(file_path)
            .expect("Couldn't get automerge text object, so not able to modify it");
        let mut offset = 0isize;

        let Some(Content::String(text)) = self.current_file_content(file_path) else {
            bail!("File {file_path} not initialized before applying delta");
        };
        // Clone so that `self` is no longer borrowed.
        let text = text.clone();

        // TODO: Can we avoid/handle this panic, same as the one below?
        let ed_delta = EditorTextDelta::try_from_delta(delta.clone(), &text)?;

        for op in &ed_delta.0 {
            // TODO: Can we avoid/handle this panic, e.g. by undoing previous deltas of this set?
            let (start, length) = op.range.as_relative(&text)?;
            self.doc
                .splice_text(
                    text_obj.clone(),
                    ((start as isize) + offset) as usize,
                    length as isize,
                    &op.replacement,
                )
                .expect("Failed to splice Automerge text object");
            offset -= length as isize;
            offset += op.replacement.chars().count() as isize;
        }

        // Update cached content.
        let Some(Content::String(old_text)) = self.files.get(file_path) else {
            bail!("Did not find text content in files cache at file_path");
        };
        let new_text = delta.apply_to(old_text)?;
        self.files
            .insert(file_path.clone(), Content::String(new_text));

        Ok(())
    }

    pub fn current_file_content(&self, file_path: &RelativePath) -> Option<&Content> {
        self.files.get(file_path)
    }

    // TODO: Refactor such that file_content_at can also return bytes (and get rid of get_bytes_at)
    pub fn file_content_at(
        &self,
        file_path: &RelativePath,
        heads: &[ChangeHash],
    ) -> Result<String> {
        self.text_obj_at(file_path, heads).map(|to| {
            self.doc
                .text_at(to, heads)
                .expect("Failed to get string from Automerge text object")
        })
    }

    /// Used to get the contents of a binary file at a specific state.
    pub fn get_bytes_at(&self, file_path: &RelativePath, heads: &[ChangeHash]) -> Result<Vec<u8>> {
        let file_map = self
            .top_level_map_obj("files")
            .expect("Failed to get files Map object");

        // If the content hasn't changed, don't write to the file. This prevents irrelevant watcher events.

        match self.doc.get_at(&file_map, file_path, heads) {
            Ok(Some((Value::Scalar(Cow::Owned(ScalarValue::Bytes(bytes))), _))) => Ok(bytes),
            Ok(Some((Value::Scalar(Cow::Borrowed(ScalarValue::Bytes(bytes))), _))) => {
                // TODO: Potentially memory-heavy operation. Figure out how to deal with Cows. Moo.
                Ok(bytes.clone())
            }
            _ => {
                bail!("Failed to provide contents of binary file at {file_path}");
            }
        }
    }

    // This function is used to integrate text that was changed while the daemon was offline.
    // We need to calculate the patches compared to the current CRDT content, and apply them.
    pub fn update_text(
        &mut self,
        desired_text: &str,
        file_path: &RelativePath,
    ) -> Option<TextDelta> {
        if self.text_obj(file_path).is_ok() {
            let Some(Content::String(current_text)) = self.current_file_content(file_path) else {
                panic!("Failed to get {file_path} text object");
            };

            let chunks = dissimilar::diff(current_text, desired_text);
            if let [] | [Chunk::Equal(_)] = chunks.as_slice() {
                return None;
            }

            let text_delta: TextDelta = chunks.into();
            info!("Detected change of {file_path}. Updating.");
            debug!("Full delta of this update: {text_delta}");
            self.apply_delta_to_doc(&text_delta, file_path).expect("Failed to apply delta to document while updating text. Probably the delta doesn't fit the document content.");

            self.files.insert(
                file_path.clone(),
                Content::String(String::from(desired_text)),
            );

            Some(text_delta)
        } else {
            // The file doesn't exist in the CRDT yet, so we need to initialize it.
            self.set_file(Content::String(desired_text.to_string()), file_path);
            None
        }
    }

    pub fn remove_file(&mut self, file_path: &RelativePath) {
        if !self.file_exists(file_path) {
            debug!("Failed to get {file_path} key object, so I can't remove it from the CRDT.");
            return;
        }

        info!("Removing {file_path} from the Teamtype history.");

        // Remove from Automerge document.
        let file_map = self
            .top_level_map_obj("files")
            .expect("Failed to get files Map object");
        self.doc
            .delete(file_map, file_path)
            .expect("Failed to delete text object");

        // Remove from files cache.
        self.files.remove(file_path);
    }

    pub fn set_file(&mut self, content: Content, file_path: &RelativePath) {
        match content {
            Content::String(ref text) => {
                info!("Initializing {file_path} in the Teamtype history.");
                self.files
                    .insert(file_path.clone(), Content::String(text.clone()));

                // Now it should definitely work?
                let file_map = self
                    .top_level_map_obj("files")
                    .expect("Failed to get files Map object");

                let text_obj = self
                    .doc
                    .put_object(file_map, file_path, ObjType::Text)
                    .expect("Failed to initialize text object in Automerge document");
                self.doc
                    .splice_text(text_obj, 0, 0, text)
                    .expect("Failed to splice text into Automerge text object");
            }
            Content::Bytes(ref bytes) => {
                // If the content hasn't changed, don't write to the file. This prevents irrelevant watcher events.
                if let Some(Content::Bytes(current_bytes)) = self.current_file_content(file_path)
                    && current_bytes == bytes
                {
                    return;
                }

                // Update Automerge document.
                let file_map = self
                    .top_level_map_obj("files")
                    .expect("Failed to get files Map object");

                // If the file was not in the document before, log this.
                if !self.file_exists(file_path) {
                    info!("Initializing binary {file_path} in the Teamtype history.");
                }

                self.doc
                    .put(file_map, file_path, bytes.clone())
                    .expect("Failed to initialize bytes object in Automerge document");
            }
        }
        // Update file cache.
        self.files.insert(file_path.clone(), content);
    }

    fn update_files(&mut self) {
        if let Ok(file_map) = self.top_level_map_obj("files") {
            for file_path in self.doc.keys(&file_map) {
                match self.doc.get(&file_map, &file_path) {
                    Ok(Some((Value::Object(ObjType::Text), text_obj))) => {
                        let string = self
                            .doc
                            .text(&text_obj)
                            .expect("Should be able to retrieve text");
                        self.files
                            .insert(RelativePath::new(&file_path), Content::String(string));
                    }
                    Ok(Some((Value::Scalar(Cow::Owned(ScalarValue::Bytes(bytes))), _))) => {
                        self.files
                            .insert(RelativePath::new(&file_path), Content::Bytes(bytes));
                    }
                    Ok(Some((Value::Scalar(Cow::Borrowed(ScalarValue::Bytes(bytes))), _))) => {
                        // TODO: Potentially memory-heavy operation. Figure out how to deal with Cows. Moo.
                        self.files
                            .insert(RelativePath::new(&file_path), Content::Bytes(bytes.clone()));
                    }
                    _ => {
                        panic!("Found unexpected object while iterating over Automerge document");
                    }
                }
            }
        }
    }

    fn top_level_map_obj(&self, name: &str) -> Result<ObjId> {
        let file_map = self.doc.get(automerge::ROOT, name);
        if let Ok(Some((Value::Object(ObjType::Map), file_map))) = file_map {
            Ok(file_map)
        } else {
            bail!("Automerge document doesn't have a {name} Map object")
        }
    }

    fn text_obj(&self, file_path: &RelativePath) -> Result<ObjId> {
        let file_map = self.top_level_map_obj("files")?;
        let text_obj = self
            .doc
            .get(file_map, file_path)
            .with_context(|| format!("Failed to get {file_path} key from Automerge document"))?;
        if let Some((Value::Object(ObjType::Text), text_obj)) = text_obj {
            Ok(text_obj)
        } else {
            bail!(
                "Automerge document doesn't have a {file_path} Text object, so I can't provide it"
            )
        }
    }

    fn text_obj_at(&self, file_path: &RelativePath, heads: &[ChangeHash]) -> Result<ObjId> {
        // I hope this one is always there...
        let file_map = self.top_level_map_obj("files")?;

        let text_obj = self
            .doc
            .get_at(file_map, file_path, heads)
            .with_context(|| format!("Failed to get {file_path} key from Automerge document"))?;
        if let Some((Value::Object(ObjType::Text), text_obj)) = text_obj {
            Ok(text_obj)
        } else {
            bail!(
                "Automerge document doesn't have a {file_path} Text object, so I can't provide it"
            )
        }
    }

    #[must_use]
    pub fn files(&self) -> Vec<RelativePath> {
        self.files.keys().map(Clone::clone).collect()
    }

    #[must_use]
    pub fn file_exists(&self, file_path: &RelativePath) -> bool {
        self.files.contains_key(file_path)
    }

    #[must_use]
    pub fn get_heads(&mut self) -> Vec<ChangeHash> {
        self.doc.get_heads()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::factories::*;

    impl Document {
        fn assert_file_content(&self, file_path: &RelativePath, content: &str) {
            assert_eq!(
                self.current_file_content(file_path),
                Some(Content::String(content.to_string())).as_ref()
            );
        }
    }

    #[test]
    fn can_initialize_content() {
        let mut document = Document::default();
        let text = "To be or not to be, that is the question";
        let file = RelativePath::new("text");

        document.set_file(Content::String(text.to_string()), &file);

        document.assert_file_content(&file, text);
    }

    #[test]
    fn can_initialize_content_multifile() {
        let mut document = Document::default();

        let text = "To be or not to be, that is the question";
        let text2 = "2b||!2b, that is the question";

        let file1 = RelativePath::new("text");
        let file2 = RelativePath::new("text2");

        document.set_file(Content::String(text.to_string()), &file1);
        document.set_file(Content::String(text2.to_string()), &file2);

        document.assert_file_content(&file1, text);
        document.assert_file_content(&file2, text2);
    }

    #[test]
    fn retrieve_content_file_nonexistent_errs() {
        let document = Document::default();
        assert!(
            document
                .current_file_content(&RelativePath::new("text"))
                .is_none()
        );
    }

    fn apply_delta_to_doc_works(initial: &str, delta: &TextDelta, expected: &str) {
        let mut document = Document::default();
        let file = RelativePath::new("text");

        document.set_file(Content::String(initial.to_string()), &file);
        document.apply_delta_to_doc(delta, &file).unwrap();

        document.assert_file_content(&file, expected);
    }

    #[test]
    fn can_apply_delta_basic_insertion() {
        let delta = insert(0, "foobar");
        apply_delta_to_doc_works("", &delta, "foobar");
    }

    #[test]
    fn can_apply_delta_basic_deletion() {
        let delta = delete(3, 3);
        apply_delta_to_doc_works("foobar", &delta, "foo");
    }

    #[test]
    fn can_apply_delta_basic_replacement() {
        let delta = replace(1, 2, "uu");
        apply_delta_to_doc_works("foobar", &delta, "fuubar");
    }

    #[test]
    fn can_apply_delta_multiple_ops() {
        let initial_text = "To be or not to be, that is the question";

        let mut delta = insert(3, "m");
        delta.delete(1); // "b"
        delta.retain(5); // "e or "
        delta.delete(4); // "not "
        delta.retain(3); // "to "
        delta.delete(2); // "be"
        delta.insert("you");

        apply_delta_to_doc_works(
            initial_text,
            &delta,
            "To me or to you, that is the question",
        );
    }

    #[test]
    fn apply_delta_only_changes_specified_file() {
        let mut document = Document::default();

        let file1 = RelativePath::new("text");
        let file2 = RelativePath::new("text2");

        document.set_file(Content::String(String::new()), &file1);
        document.set_file(Content::String(String::new()), &file2);

        let delta = insert(0, "foobar");
        document.apply_delta_to_doc(&delta, &file1).unwrap();

        document.assert_file_content(&file1, "foobar");
        document.assert_file_content(&file2, "");
    }

    /// This set of tests has some documentation character to show to ourselves,
    /// what happens under the hood when sync'ing.
    mod automerge_interna {
        use super::*;

        #[test]
        fn test_generate_sync_message() {
            let mut document = Document::default();
            let mut state = AutomergeSyncState::new();
            assert!(document.generate_sync_message(&mut state).is_some());
            // Stops for now and waits for a response
            assert!(document.generate_sync_message(&mut state).is_none());

            document.set_file(Content::String(String::new()), &RelativePath::new("text"));
            // We have progressed our state, so update all peers about that.
            assert!(document.generate_sync_message(&mut state).is_some());
            // Again, stop, until peers tell us if they want more information.
            assert!(document.generate_sync_message(&mut state).is_none());
        }
    }
}
