use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::io::Write as _;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use futures::StreamExt as _;
use jj_lib::conflicts::ConflictMarkerStyle;
use jj_lib::fsmonitor::FsmonitorSettings;
use jj_lib::gitignore::GitIgnoreFile;
use jj_lib::local_working_copy::EolConversionMode;
use jj_lib::local_working_copy::ExecChangeSetting;
use jj_lib::local_working_copy::TreeState;
use jj_lib::local_working_copy::TreeStateError;
use jj_lib::local_working_copy::TreeStateSettings;
use jj_lib::matchers::EverythingMatcher;
use jj_lib::matchers::Matcher;
use jj_lib::matchers::NothingMatcher;
use jj_lib::merge::Diff;
use jj_lib::merged_tree::MergedTree;
use jj_lib::merged_tree::TreeDiffEntry;
use jj_lib::repo_path::RepoPath;
use jj_lib::repo_path::RepoPathBuf;
use jj_lib::working_copy::CheckoutError;
use jj_lib::working_copy::SnapshotOptions;
use tempfile::TempDir;
use thiserror::Error;

use super::DiffEditError;
use super::external::ExternalToolError;

#[derive(Debug, Error)]
pub enum DiffCheckoutError {
    #[error("Failed to write directories to diff")]
    Checkout(#[from] CheckoutError),
    #[error("Error setting up temporary directory")]
    SetUpDir(#[source] std::io::Error),
    #[error(transparent)]
    TreeState(#[from] TreeStateError),
}

pub(crate) struct DiffWorkingCopies {
    temp_dir: TempDir, // Temp dir will be deleted when this is dropped
    left: TreeState,
    right: TreeState,
    output: Option<TreeState>,
}

impl DiffWorkingCopies {
    pub fn set_left_readonly(&self) -> Result<(), ExternalToolError> {
        set_readonly_recursively(self.left.working_copy_path()).map_err(ExternalToolError::SetUpDir)
    }

    pub fn set_right_readonly(&self) -> Result<(), ExternalToolError> {
        set_readonly_recursively(self.right.working_copy_path())
            .map_err(ExternalToolError::SetUpDir)
    }

    pub fn temp_dir(&self) -> &Path {
        self.temp_dir.path()
    }

    /// The paths of the files that were checked out to disk.
    pub fn checked_out_files(&self) -> &[RepoPathBuf] {
        debug_assert_eq!(self.left.sparse_patterns(), self.right.sparse_patterns());
        if let Some(output) = &self.output {
            debug_assert_eq!(self.left.sparse_patterns(), output.sparse_patterns());
        }
        self.left.sparse_patterns()
    }

    /// Returns command variables (`$left`, `$right`, and optionally `$output`)
    /// for a single checked-out file at `repo_path`. The paths are absolute
    /// unless `relative` is set, in which case they're relative to
    /// [`Self::temp_dir`].
    pub fn to_command_variables_for_file(
        &self,
        repo_path: &RepoPath,
        relative: bool,
    ) -> HashMap<&'static str, String> {
        let fs_path = |base: &Path, relative: bool| {
            let base = if relative {
                base.strip_prefix(self.temp_dir())
                    .expect("path should be relative to temp_dir")
            } else {
                base
            };
            repo_path
                .to_fs_path(base)
                .expect("path should have been checked out to disk")
                .into_os_string()
                .into_string()
                .expect("temp_dir should be valid utf-8")
        };
        let mut result = maplit::hashmap! {
            "left" => fs_path(self.left.working_copy_path(), relative),
            "right" => fs_path(self.right.working_copy_path(), relative),
        };
        if let Some(output_state) = &self.output {
            result.insert(
                "output",
                fs_path(output_state.working_copy_path(), relative),
            );
        }
        result
    }

    pub fn to_command_variables(&self, relative: bool) -> HashMap<&'static str, String> {
        self.to_command_variables_for_file(RepoPath::root(), relative)
    }
}

pub(crate) fn new_utf8_temp_dir(prefix: &str) -> io::Result<TempDir> {
    let temp_dir = tempfile::Builder::new().prefix(prefix).tempdir()?;
    if temp_dir.path().to_str().is_none() {
        // Not using .display() as we know the path contains unprintable character
        let message = format!("path {:?} is not valid UTF-8", temp_dir.path());
        return Err(io::Error::new(io::ErrorKind::InvalidData, message));
    }
    Ok(temp_dir)
}

pub(crate) fn set_readonly_recursively(path: &Path) -> Result<(), std::io::Error> {
    // Directory permission is unchanged since files under readonly directory cannot
    // be removed.
    let metadata = path.symlink_metadata()?;
    if metadata.is_dir() {
        for entry in path.read_dir()? {
            set_readonly_recursively(&entry?.path())?;
        }
        Ok(())
    } else if metadata.is_file() {
        let mut perms = metadata.permissions();
        perms.set_readonly(true);
        std::fs::set_permissions(path, perms)
    } else {
        Ok(())
    }
}

/// How to prepare tree states from the working copy for a diff viewer/editor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiffType {
    /// Prepare a left and right tree.
    TwoWay,
    /// Prepare left, right, and output trees.
    ThreeWay,
}

/// Check out the two trees in temporary directories. Only include changed files
/// in the sparse checkout patterns.
pub(crate) async fn check_out_trees(
    trees: Diff<&MergedTree>,
    matcher: &dyn Matcher,
    diff_type: DiffType,
    conflict_marker_style: ConflictMarkerStyle,
) -> Result<DiffWorkingCopies, DiffCheckoutError> {
    let store = trees.before.store();
    let changed_files: Vec<_> = trees
        .before
        .diff_stream(trees.after, matcher)
        .map(|TreeDiffEntry { path, .. }| path)
        .collect()
        .await;

    let temp_dir = new_utf8_temp_dir("jj-diff-").map_err(DiffCheckoutError::SetUpDir)?;
    let temp_path = temp_dir.path();

    // Checkout a tree into our temp directory with the given prefix.
    let check_out = |name: &str, tree| -> Result<TreeState, DiffCheckoutError> {
        let wc_path = temp_path.join(name);
        let state_dir = temp_path.join(format!("{name}_state"));
        std::fs::create_dir(&wc_path).map_err(DiffCheckoutError::SetUpDir)?;
        std::fs::create_dir(&state_dir).map_err(DiffCheckoutError::SetUpDir)?;
        let tree_state_settings = TreeStateSettings {
            conflict_marker_style,
            eol_conversion_mode: EolConversionMode::None,
            exec_change_setting: ExecChangeSetting::Auto,
            fsmonitor_settings: FsmonitorSettings::None,
        };
        let mut state = TreeState::init(store.clone(), wc_path, state_dir, &tree_state_settings)?;
        state.set_sparse_patterns(changed_files.clone())?;
        state.check_out(tree)?;
        Ok(state)
    };

    let left = check_out("left", trees.before)?;
    let right = check_out("right", trees.after)?;
    let output = match diff_type {
        DiffType::TwoWay => None,
        DiffType::ThreeWay => Some(check_out("output", trees.after)?),
    };
    Ok(DiffWorkingCopies {
        temp_dir,
        left,
        right,
        output,
    })
}

pub(crate) struct DiffEditWorkingCopies {
    pub working_copies: DiffWorkingCopies,
    instructions_path_to_cleanup: Option<PathBuf>,
}

impl DiffEditWorkingCopies {
    /// Checks out the trees, populates JJ_INSTRUCTIONS, and makes appropriate
    /// sides readonly.
    pub async fn check_out(
        trees: Diff<&MergedTree>,
        matcher: &dyn Matcher,
        diff_type: DiffType,
        instructions: Option<&str>,
        conflict_marker_style: ConflictMarkerStyle,
    ) -> Result<Self, DiffEditError> {
        let working_copies =
            check_out_trees(trees, matcher, diff_type, conflict_marker_style).await?;
        working_copies.set_left_readonly()?;
        if diff_type == DiffType::ThreeWay {
            working_copies.set_right_readonly()?;
        }
        let instructions_path_to_cleanup =
            Self::write_edit_instructions(&working_copies, instructions)?;
        Ok(Self {
            working_copies,
            instructions_path_to_cleanup,
        })
    }

    fn write_edit_instructions(
        working_copies: &DiffWorkingCopies,
        instructions: Option<&str>,
    ) -> Result<Option<PathBuf>, DiffEditError> {
        let Some(instructions) = instructions else {
            return Ok(None);
        };
        let (right_wc_path, output_wc_path) = match &working_copies.output {
            Some(output) => (
                Some(working_copies.right.working_copy_path()),
                output.working_copy_path(),
            ),
            None => (None, working_copies.right.working_copy_path()),
        };
        let output_instructions_path = output_wc_path.join("JJ-INSTRUCTIONS");
        // In the unlikely event that the file already exists, then the user will simply
        // not get any instructions.
        if output_instructions_path.exists() {
            return Ok(None);
        }
        let mut output_instructions_file =
            File::create(&output_instructions_path).map_err(ExternalToolError::SetUpDir)?;

        // Write out our experimental three-way merge instructions first.
        if let Some(right_wc_path) = right_wc_path {
            let mut right_instructions_file = File::create(right_wc_path.join("JJ-INSTRUCTIONS"))
                .map_err(ExternalToolError::SetUpDir)?;
            right_instructions_file
                .write_all(
                    b"\
The content of this pane should NOT be edited. Any edits will be
lost.

You are using the experimental 3-pane diff editor config. Some of
the following instructions may have been written with a 2-pane
diff editing in mind and be a little inaccurate.

",
                )
                .map_err(ExternalToolError::SetUpDir)?;
            right_instructions_file
                .write_all(instructions.as_bytes())
                .map_err(ExternalToolError::SetUpDir)?;
            // Note that some diff tools might not show this message and delete the contents
            // of the output dir instead. Meld does show this message.
            output_instructions_file
                .write_all(
                    b"\
Please make your edits in this pane.

You are using the experimental 3-pane diff editor config. Some of
the following instructions may have been written with a 2-pane
diff editing in mind and be a little inaccurate.

",
                )
                .map_err(ExternalToolError::SetUpDir)?;
        }
        // Now write the passed-in instructions.
        output_instructions_file
            .write_all(instructions.as_bytes())
            .map_err(ExternalToolError::SetUpDir)?;
        Ok(Some(output_instructions_path))
    }

    pub async fn snapshot_results(
        self,
        base_ignores: Arc<GitIgnoreFile>,
    ) -> Result<MergedTree, DiffEditError> {
        if let Some(path) = self.instructions_path_to_cleanup {
            std::fs::remove_file(path).ok();
        }

        let diff_wc = self.working_copies;
        // Snapshot changes in the temporary output directory.
        let mut output_tree_state = diff_wc.output.unwrap_or(diff_wc.right);
        output_tree_state
            .snapshot(&SnapshotOptions {
                base_ignores,
                progress: None,
                start_tracking_matcher: &EverythingMatcher,
                force_tracking_matcher: &NothingMatcher,
                max_new_file_size: u64::MAX,
            })
            .await?;
        Ok(output_tree_state.current_tree().clone())
    }
}
