use std::borrow::Cow;
use std::path::Path;
use std::sync::Arc;

use itertools::Itertools;
use jj_lib::backend::{BackendError, FileId, MergedTreeId, ObjectId, TreeValue};
use jj_lib::diff::{find_line_ranges, Diff, DiffHunk};
use jj_lib::files::{self, ContentHunk, MergeResult};
use jj_lib::matchers::Matcher;
use jj_lib::merge::Merge;
use jj_lib::merged_tree::{MergedTree, MergedTreeBuilder};
use jj_lib::repo_path::RepoPath;
use jj_lib::store::Store;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum BuiltinToolError {
    #[error("Failed to record changes: {0}")]
    Record(#[from] scm_record::RecordError),
    #[error(transparent)]
    ReadFileBackend(BackendError),
    #[error("Failed to read file {path:?} with ID {id:?}: {source}")]
    ReadFileIo {
        path: RepoPath,
        id: FileId,
        source: std::io::Error,
    },
    #[error(transparent)]
    ReadSymlink(BackendError),
    #[error("Failed to decode UTF-8 text for item {item} (this should not happen): {source}")]
    DecodeUtf8 {
        source: std::str::Utf8Error,
        item: &'static str,
    },
    #[error("Rendering {item} {id} is unimplemented for the builtin difftool/mergetool")]
    Unimplemented { item: &'static str, id: String },
    #[error("Backend error: {0:?}")]
    BackendError(#[from] jj_lib::backend::BackendError),
}

#[derive(Clone, Debug)]
enum FileContents {
    Absent,
    Text {
        contents: String,
        hash: Option<String>,
        num_bytes: u64,
    },
    Binary {
        hash: Option<String>,
        num_bytes: u64,
    },
}

/// Information about a file that was read from disk. Note that the file may not
/// have existed, in which case its contents will be marked as absent.
#[derive(Clone, Debug)]
pub struct FileInfo {
    file_mode: scm_record::FileMode,
    contents: FileContents,
}

/// File modes according to the Git file mode conventions. used for display
/// purposes and equality comparison.
///
/// TODO: let `scm-record` accept strings instead of numbers for file modes? Or
/// figure out some other way to represent file mode changes in a jj-compatible
/// manner?
mod mode {
    pub const NORMAL: usize = 0o100644;
    pub const EXECUTABLE: usize = 0o100755;
    pub const SYMLINK: usize = 0o120000;
}

fn describe_binary(hash: Option<&str>, num_bytes: u64) -> String {
    match hash {
        Some(hash) => {
            format!("{hash} ({num_bytes}B)")
        }
        None => format!("({num_bytes}B)"),
    }
}

fn buf_to_file_contents(hash: Option<String>, buf: Vec<u8>) -> FileContents {
    let num_bytes: u64 = buf.len().try_into().unwrap();
    let text = if buf.contains(&0) {
        None
    } else {
        String::from_utf8(buf).ok()
    };
    match text {
        Some(text) => FileContents::Text {
            contents: text,
            hash,
            num_bytes,
        },
        None => FileContents::Binary { hash, num_bytes },
    }
}

fn read_file_contents(
    store: &Store,
    tree: &MergedTree,
    path: &RepoPath,
) -> Result<FileInfo, BuiltinToolError> {
    match tree.path_value(path).into_resolved() {
        Ok(None) => Ok(FileInfo {
            file_mode: scm_record::FileMode::absent(),
            contents: FileContents::Absent,
        }),

        Ok(Some(TreeValue::File { id, executable })) => {
            let mut reader = store
                .read_file(path, &id)
                .map_err(BuiltinToolError::ReadFileBackend)?;
            let mut buf = Vec::new();
            reader
                .read_to_end(&mut buf)
                .map_err(|err| BuiltinToolError::ReadFileIo {
                    path: path.clone(),
                    id: id.clone(),
                    source: err,
                })?;

            let file_mode = if executable {
                scm_record::FileMode(mode::EXECUTABLE)
            } else {
                scm_record::FileMode(mode::NORMAL)
            };
            let contents = buf_to_file_contents(Some(id.hex()), buf);
            Ok(FileInfo {
                file_mode,
                contents,
            })
        }

        Ok(Some(TreeValue::Symlink(id))) => {
            let value = store
                .read_symlink(path, &id)
                .map_err(BuiltinToolError::ReadSymlink)?;
            let file_mode = scm_record::FileMode(mode::SYMLINK);
            let num_bytes = value.len().try_into().unwrap();
            Ok(FileInfo {
                file_mode,
                contents: FileContents::Text {
                    contents: value,
                    hash: Some(id.hex()),
                    num_bytes,
                },
            })
        }

        Ok(Some(TreeValue::Tree(tree_id))) => {
            unreachable!("list of changed files included a tree: {tree_id:?}");
        }
        Ok(Some(TreeValue::GitSubmodule(id))) => Err(BuiltinToolError::Unimplemented {
            item: "git submodule",
            id: id.hex(),
        }),
        Ok(Some(TreeValue::Conflict(id))) => {
            unreachable!("list of changed files included a conflict: {id:?}");
        }
        Err(merge) => {
            unreachable!("list of changed files included a merge: {merge:?}");
        }
    }
}

fn make_section_changed_lines(
    contents: &str,
    change_type: scm_record::ChangeType,
) -> Vec<scm_record::SectionChangedLine<'static>> {
    contents
        .split_inclusive('\n')
        .map(|line| scm_record::SectionChangedLine {
            is_checked: false,
            change_type,
            line: Cow::Owned(line.to_owned()),
        })
        .collect()
}

fn make_diff_sections(
    left_contents: &str,
    right_contents: &str,
) -> Result<Vec<scm_record::Section<'static>>, BuiltinToolError> {
    let diff = Diff::for_tokenizer(
        &[left_contents.as_bytes(), right_contents.as_bytes()],
        &find_line_ranges,
    );
    let mut sections = Vec::new();
    for hunk in diff.hunks() {
        match hunk {
            DiffHunk::Matching(text) => {
                let text =
                    std::str::from_utf8(text).map_err(|err| BuiltinToolError::DecodeUtf8 {
                        source: err,
                        item: "matching text in diff hunk",
                    })?;
                sections.push(scm_record::Section::Unchanged {
                    lines: text
                        .split_inclusive('\n')
                        .map(|line| Cow::Owned(line.to_owned()))
                        .collect(),
                })
            }
            DiffHunk::Different(sides) => {
                assert_eq!(sides.len(), 2, "only two inputs were provided to the diff");
                let left_side =
                    std::str::from_utf8(sides[0]).map_err(|err| BuiltinToolError::DecodeUtf8 {
                        source: err,
                        item: "left side of diff hunk",
                    })?;
                let right_side =
                    std::str::from_utf8(sides[1]).map_err(|err| BuiltinToolError::DecodeUtf8 {
                        source: err,
                        item: "right side of diff hunk",
                    })?;
                sections.push(scm_record::Section::Changed {
                    lines: [
                        make_section_changed_lines(left_side, scm_record::ChangeType::Removed),
                        make_section_changed_lines(right_side, scm_record::ChangeType::Added),
                    ]
                    .concat(),
                })
            }
        }
    }
    Ok(sections)
}

pub fn make_diff_files(
    store: &Arc<Store>,
    left_tree: &MergedTree,
    right_tree: &MergedTree,
    changed_files: &[RepoPath],
) -> Result<Vec<scm_record::File<'static>>, BuiltinToolError> {
    let mut files = Vec::new();
    for changed_path in changed_files {
        let FileInfo {
            file_mode: left_file_mode,
            contents: left_contents,
        } = read_file_contents(store, left_tree, changed_path)?;
        let FileInfo {
            file_mode: right_file_mode,
            contents: right_contents,
        } = read_file_contents(store, right_tree, changed_path)?;

        let mut sections = Vec::new();
        if left_file_mode != right_file_mode
            && left_file_mode != scm_record::FileMode::absent()
            && right_file_mode != scm_record::FileMode::absent()
        {
            sections.push(scm_record::Section::FileMode {
                is_checked: false,
                before: left_file_mode,
                after: right_file_mode,
            });
        }

        match (left_contents, right_contents) {
            (FileContents::Absent, FileContents::Absent) => {}
            (
                FileContents::Absent,
                FileContents::Text {
                    contents,
                    hash: _,
                    num_bytes: _,
                },
            ) => sections.push(scm_record::Section::Changed {
                lines: make_section_changed_lines(&contents, scm_record::ChangeType::Added),
            }),

            (FileContents::Absent, FileContents::Binary { hash, num_bytes }) => {
                sections.push(scm_record::Section::Binary {
                    is_checked: false,
                    old_description: None,
                    new_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))),
                })
            }

            (
                FileContents::Text {
                    contents,
                    hash: _,
                    num_bytes: _,
                },
                FileContents::Absent,
            ) => sections.push(scm_record::Section::Changed {
                lines: make_section_changed_lines(&contents, scm_record::ChangeType::Removed),
            }),

            (
                FileContents::Text {
                    contents: old_contents,
                    hash: _,
                    num_bytes: _,
                },
                FileContents::Text {
                    contents: new_contents,
                    hash: _,
                    num_bytes: _,
                },
            ) => {
                sections.extend(make_diff_sections(&old_contents, &new_contents)?);
            }

            (
                FileContents::Text {
                    contents: _,
                    hash: old_hash,
                    num_bytes: old_num_bytes,
                }
                | FileContents::Binary {
                    hash: old_hash,
                    num_bytes: old_num_bytes,
                },
                FileContents::Text {
                    contents: _,
                    hash: new_hash,
                    num_bytes: new_num_bytes,
                }
                | FileContents::Binary {
                    hash: new_hash,
                    num_bytes: new_num_bytes,
                },
            ) => sections.push(scm_record::Section::Binary {
                is_checked: false,
                old_description: Some(Cow::Owned(describe_binary(
                    old_hash.as_deref(),
                    old_num_bytes,
                ))),
                new_description: Some(Cow::Owned(describe_binary(
                    new_hash.as_deref(),
                    new_num_bytes,
                ))),
            }),

            (FileContents::Binary { hash, num_bytes }, FileContents::Absent) => {
                sections.push(scm_record::Section::Binary {
                    is_checked: false,
                    old_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))),
                    new_description: None,
                })
            }
        }

        files.push(scm_record::File {
            old_path: None,
            path: Cow::Owned(changed_path.to_fs_path(Path::new(""))),
            file_mode: None,
            sections,
        });
    }
    Ok(files)
}

pub fn apply_diff_builtin(
    store: Arc<Store>,
    left_tree: &MergedTree,
    right_tree: &MergedTree,
    changed_files: Vec<RepoPath>,
    files: &[scm_record::File],
) -> Result<MergedTreeId, BackendError> {
    let mut tree_builder = MergedTreeBuilder::new(left_tree.id().clone());
    assert_eq!(
        changed_files.len(),
        files.len(),
        "result had a different number of files"
    );
    for (path, file) in changed_files.into_iter().zip(files) {
        let (selected, _unselected) = file.get_selected_contents();
        match selected {
            scm_record::SelectedContents::Absent => {
                tree_builder.set_or_remove(path, Merge::absent());
            }
            scm_record::SelectedContents::Unchanged => {
                // Do nothing.
            }
            scm_record::SelectedContents::Binary {
                old_description: _,
                new_description: _,
            } => {
                let value = right_tree.path_value(&path);
                tree_builder.set_or_remove(path, value);
            }
            scm_record::SelectedContents::Present { contents } => {
                let file_id = store.write_file(&path, &mut contents.as_bytes())?;
                tree_builder.set_or_remove(
                    path,
                    Merge::normal(TreeValue::File {
                        id: file_id,
                        executable: file.get_file_mode()
                            == Some(scm_record::FileMode(mode::EXECUTABLE)),
                    }),
                )
            }
        }
    }

    let tree_id = tree_builder.write_tree(left_tree.store())?;
    Ok(tree_id)
}

pub fn edit_diff_builtin(
    left_tree: &MergedTree,
    right_tree: &MergedTree,
    matcher: &dyn Matcher,
) -> Result<MergedTreeId, BuiltinToolError> {
    let store = left_tree.store().clone();
    let changed_files: Vec<_> = left_tree
        .diff(right_tree, matcher)
        .map(|(path, diff)| diff.map(|_| path))
        .try_collect()?;
    let files = make_diff_files(&store, left_tree, right_tree, &changed_files)?;
    let recorder = scm_record::Recorder::new(
        scm_record::RecordState {
            is_read_only: false,
            files,
        },
        scm_record::EventSource::Crossterm,
    );
    let result = recorder.run().map_err(BuiltinToolError::Record)?;
    let tree_id = apply_diff_builtin(store, left_tree, right_tree, changed_files, &result.files)
        .map_err(BuiltinToolError::BackendError)?;
    Ok(tree_id)
}

fn make_merge_sections(
    merge_result: MergeResult,
) -> Result<Vec<scm_record::Section<'static>>, BuiltinToolError> {
    let mut sections = Vec::new();
    match merge_result {
        MergeResult::Resolved(ContentHunk(buf)) => {
            let contents = buf_to_file_contents(None, buf);
            let section = match contents {
                FileContents::Absent => None,
                FileContents::Text {
                    contents,
                    hash: _,
                    num_bytes: _,
                } => Some(scm_record::Section::Unchanged {
                    lines: contents
                        .split_inclusive('\n')
                        .map(|line| Cow::Owned(line.to_owned()))
                        .collect(),
                }),
                FileContents::Binary { hash, num_bytes } => Some(scm_record::Section::Binary {
                    is_checked: false,
                    old_description: None,
                    new_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))),
                }),
            };
            if let Some(section) = section {
                sections.push(section);
            }
        }
        MergeResult::Conflict(hunks) => {
            for hunk in hunks {
                let section = match hunk.into_resolved() {
                    Ok(ContentHunk(contents)) => {
                        let contents = std::str::from_utf8(&contents).map_err(|err| {
                            BuiltinToolError::DecodeUtf8 {
                                source: err,
                                item: "unchanged hunk",
                            }
                        })?;
                        scm_record::Section::Unchanged {
                            lines: contents
                                .split_inclusive('\n')
                                .map(|line| Cow::Owned(line.to_owned()))
                                .collect(),
                        }
                    }
                    Err(merge) => {
                        let lines: Vec<scm_record::SectionChangedLine> = merge
                            .iter()
                            .zip(
                                [
                                    scm_record::ChangeType::Added,
                                    scm_record::ChangeType::Removed,
                                ]
                                .into_iter()
                                .cycle(),
                            )
                            .map(|(contents, change_type)| -> Result<_, BuiltinToolError> {
                                let ContentHunk(contents) = contents;
                                let contents = std::str::from_utf8(contents).map_err(|err| {
                                    BuiltinToolError::DecodeUtf8 {
                                        source: err,
                                        item: "conflicting hunk",
                                    }
                                })?;
                                let changed_lines =
                                    make_section_changed_lines(contents, change_type);
                                Ok(changed_lines)
                            })
                            .flatten_ok()
                            .try_collect()?;
                        scm_record::Section::Changed { lines }
                    }
                };
                sections.push(section);
            }
        }
    }
    Ok(sections)
}

pub fn edit_merge_builtin(
    tree: &MergedTree,
    path: &RepoPath,
    content: Merge<ContentHunk>,
) -> Result<MergedTreeId, BuiltinToolError> {
    let slices = content.map(|ContentHunk(v)| v.as_slice());
    let merge_result = files::merge(slices);
    let sections = make_merge_sections(merge_result)?;
    let recorder = scm_record::Recorder::new(
        scm_record::RecordState {
            is_read_only: false,
            files: vec![scm_record::File {
                old_path: None,
                path: Cow::Owned(path.to_fs_path(Path::new(""))),
                file_mode: None,
                sections,
            }],
        },
        scm_record::EventSource::Crossterm,
    );
    let state = recorder.run()?;

    let file = state.files.into_iter().exactly_one().unwrap();
    apply_diff_builtin(
        tree.store().clone(),
        tree,
        tree,
        vec![path.clone()],
        &[file],
    )
    .map_err(BuiltinToolError::BackendError)
}

#[cfg(test)]
mod tests {
    use futures::executor::block_on;
    use jj_lib::conflicts::extract_as_single_hunk;
    use jj_lib::merge::MergedTreeValue;
    use jj_lib::repo::Repo;
    use testutils::TestRepo;

    use super::*;

    #[test]
    fn test_edit_diff_builtin() {
        let test_repo = TestRepo::init();
        let store = test_repo.repo.store();

        let unused_path = RepoPath::from_internal_string("unused");
        let unchanged = RepoPath::from_internal_string("unchanged");
        let changed_path = RepoPath::from_internal_string("changed");
        let added_path = RepoPath::from_internal_string("added");
        let left_tree = testutils::create_tree(
            &test_repo.repo,
            &[
                (&unused_path, "unused\n"),
                (&unchanged, "unchanged\n"),
                (&changed_path, "line1\nline2\nline3\n"),
            ],
        );
        let right_tree = testutils::create_tree(
            &test_repo.repo,
            &[
                (&unused_path, "unused\n"),
                (&unchanged, "unchanged\n"),
                (&changed_path, "line1\nchanged1\nchanged2\nline3\nadded1\n"),
                (&added_path, "added\n"),
            ],
        );

        let changed_files = vec![unchanged.clone(), changed_path.clone(), added_path.clone()];
        let files = make_diff_files(store, &left_tree, &right_tree, &changed_files).unwrap();
        insta::assert_debug_snapshot!(files, @r###"
        [
            File {
                old_path: None,
                path: "unchanged",
                file_mode: None,
                sections: [
                    Unchanged {
                        lines: [
                            "unchanged\n",
                        ],
                    },
                ],
            },
            File {
                old_path: None,
                path: "changed",
                file_mode: None,
                sections: [
                    Unchanged {
                        lines: [
                            "line1\n",
                        ],
                    },
                    Changed {
                        lines: [
                            SectionChangedLine {
                                is_checked: false,
                                change_type: Removed,
                                line: "line2\n",
                            },
                            SectionChangedLine {
                                is_checked: false,
                                change_type: Added,
                                line: "changed1\n",
                            },
                            SectionChangedLine {
                                is_checked: false,
                                change_type: Added,
                                line: "changed2\n",
                            },
                        ],
                    },
                    Unchanged {
                        lines: [
                            "line3\n",
                        ],
                    },
                    Changed {
                        lines: [
                            SectionChangedLine {
                                is_checked: false,
                                change_type: Added,
                                line: "added1\n",
                            },
                        ],
                    },
                ],
            },
            File {
                old_path: None,
                path: "added",
                file_mode: None,
                sections: [
                    Changed {
                        lines: [
                            SectionChangedLine {
                                is_checked: false,
                                change_type: Added,
                                line: "added\n",
                            },
                        ],
                    },
                ],
            },
        ]
        "###);

        let no_changes_tree_id = apply_diff_builtin(
            store.clone(),
            &left_tree,
            &right_tree,
            changed_files.clone(),
            &files,
        )
        .unwrap();
        let no_changes_tree = store.get_root_tree(&no_changes_tree_id).unwrap();
        assert_eq!(
            no_changes_tree.id(),
            left_tree.id(),
            "no-changes tree was different",
        );

        let mut files = files;
        for file in files.iter_mut() {
            file.toggle_all();
        }
        let all_changes_tree_id = apply_diff_builtin(
            store.clone(),
            &left_tree,
            &right_tree,
            changed_files,
            &files,
        )
        .unwrap();
        let all_changes_tree = store.get_root_tree(&all_changes_tree_id).unwrap();
        assert_eq!(
            all_changes_tree.id(),
            right_tree.id(),
            "all-changes tree was different",
        );
    }

    #[test]
    fn test_make_merge_sections() {
        let test_repo = TestRepo::init();
        let store = test_repo.repo.store();

        let path = RepoPath::from_internal_string("file");
        let base_tree = testutils::create_tree(
            &test_repo.repo,
            &[(&path, "base 1\nbase 2\nbase 3\nbase 4\nbase 5\n")],
        );
        let left_tree = testutils::create_tree(
            &test_repo.repo,
            &[(&path, "left 1\nbase 2\nbase 3\nbase 4\nleft 5\n")],
        );
        let right_tree = testutils::create_tree(
            &test_repo.repo,
            &[(&path, "right 1\nbase 2\nbase 3\nbase 4\nright 5\n")],
        );

        fn to_file_id(tree_value: MergedTreeValue) -> Option<FileId> {
            match tree_value.into_resolved() {
                Ok(Some(TreeValue::File { id, executable: _ })) => Some(id.clone()),
                other => {
                    panic!("merge should have been a FileId: {other:?}")
                }
            }
        }
        let merge = Merge::new(
            vec![to_file_id(base_tree.path_value(&path))],
            vec![
                to_file_id(left_tree.path_value(&path)),
                to_file_id(right_tree.path_value(&path)),
            ],
        );
        let content = block_on(extract_as_single_hunk(&merge, store, &path));
        let slices = content.map(|ContentHunk(buf)| buf.as_slice());
        let merge_result = files::merge(slices);
        let sections = make_merge_sections(merge_result).unwrap();
        insta::assert_debug_snapshot!(sections, @r###"
        [
            Changed {
                lines: [
                    SectionChangedLine {
                        is_checked: false,
                        change_type: Added,
                        line: "left 1\n",
                    },
                    SectionChangedLine {
                        is_checked: false,
                        change_type: Removed,
                        line: "base 1\n",
                    },
                    SectionChangedLine {
                        is_checked: false,
                        change_type: Added,
                        line: "right 1\n",
                    },
                ],
            },
            Unchanged {
                lines: [
                    "base 2\n",
                    "base 3\n",
                    "base 4\n",
                ],
            },
            Changed {
                lines: [
                    SectionChangedLine {
                        is_checked: false,
                        change_type: Added,
                        line: "left 5\n",
                    },
                    SectionChangedLine {
                        is_checked: false,
                        change_type: Removed,
                        line: "base 5\n",
                    },
                    SectionChangedLine {
                        is_checked: false,
                        change_type: Added,
                        line: "right 5\n",
                    },
                ],
            },
        ]
        "###);
    }
}
