use crate::common::{
    apply, diff, diff_meta, git, git::Git, fork_point::ForkPoint, history, lines::TypedLines,
};
use format_bytes::format_bytes;
use std::{cell::OnceCell, future::Future, rc::Rc};
use super::blocks::{Blocks, ResolutionCache, ResolutionCacheEntry};
use super::filesystem::SharedVirtualFilesystem;
use super::format::{Formatted, Style};

pub struct Commit {
    pub(crate) files: Vec<CommitFile>,
    /// Present only if this `Commit` is part of a `Workspace` with
    /// `store_mode` == `StoreMode::Commit`.
    metadata: Option<CommitMetadata>,
}

pub struct CommitMetadata {
    /// The original upstream commit description.
    description: Rc<git::Description>,
    /// The fork point from upstream for this commit. Evaluated lazily.
    pub(crate) fork_point: ForkPoint,
}

pub struct CommitFile {
    /// Upstream metadata of this file.
    pub(crate) upstream_metadata: diff_meta::Metadata,
    /// Upstream diff of this file.
    upstream_diff: Vec<diff::Hunk>,
    /// The picker of the file to which the diff should be applied.
    file_picker: FilePicker,
    /// The historical commits to this file. Evaluated lazily.
    pub(crate) file_history: OnceCell<history::FileHistory>,
    /// The applied blocks. None if the commit is not applied.
    pub(crate) blocks: Option<Blocks>,
    /// The resolution cache.
    resolution_cache: ResolutionCache,
}

#[derive(Clone, derive_more::From)]
pub(crate) enum FilePicker {
    FromUpstream(FileFromUpstream),
    FromConflict(FileFromConflict),
    FromMergetool(FileFromMergetool),
}

/// A picker for a file originated from an upstream commit.
#[derive(Clone)]
pub(crate) struct FileFromUpstream {}

/// A picker for a file from a git conflict. The filesystem is ignored; downstream data
/// are always read from an internal buffer.
#[derive(Clone)]
pub(crate) struct FileFromConflict {
    blob_sha: Vec<u8>,
}

/// A mergetool file picker.
#[derive(Clone)]
pub(crate) struct FileFromMergetool {
    local_path: Vec<u8>,
    merged_path: Vec<u8>,
}

/// Description of a conflict to a file for the purpose of generating the commit message.
pub(crate) struct Conflict {
    pub(crate) path: Vec<u8>,
    pub(crate) problems: Vec<Vec<u8>>,
}

impl Commit {
    pub(crate) fn with_capacity(capacity: usize, metadata: Option<CommitMetadata>) -> Self {
        Self {
            files: Vec::with_capacity(capacity),
            metadata,
        }
    }

    pub(crate) fn push_file(
        &mut self,
        file_change: diff_meta::FileChange,
        file_picker: impl Into<FilePicker>,
    ) {
        self.files.push(CommitFile {
            upstream_metadata: file_change.metadata,
            upstream_diff: file_change.data.diff(),
            file_picker: file_picker.into(),
            file_history: OnceCell::new(),
            blocks: None,
            resolution_cache: ResolutionCache::default(),
        });
    }

    pub fn file_count(&self) -> usize {
        self.files.len()
    }

    pub(crate) fn sort_files(&mut self) {
        self.files.sort_by(|a, b|
            a.upstream_metadata.name.cmp(&b.upstream_metadata.name)
        );
    }

    /// Apply all files in separate threads. The applicators are spawned each in its own
    /// thread; they can thus run as blocking without stalling the main thread. Return futures
    /// that should be awaited to get the results. The futures are in the same order as
    /// the files in the commit.
    pub(crate) fn apply_files(
        &self,
        filesystem: &SharedVirtualFilesystem,
        upstream_repo: &Git,
    ) -> Vec<impl Future<Output = Result<apply::Applied, String>>> {
        let mut receivers = Vec::with_capacity(self.files.len());
        for file in self.files.iter() {
            let (sender, receiver) = smol::channel::bounded(1);
            let picker = file.file_picker.clone();
            let diff = file.upstream_diff.clone();
            let filesystem = filesystem.clone();
            let upstream_metadata = file.upstream_metadata.clone();
            let upstream_repo = upstream_repo.clone();
            let commit_id = self.metadata.as_ref().map(|md| md.description.id.clone());
            let fork_point = self.metadata.as_ref().map(|md| md.fork_point.clone());
            rayon::spawn_fifo(move || {
                let applied = picker.pick_lines(
                    filesystem,
                    upstream_metadata,
                    upstream_repo,
                    commit_id,
                    fork_point,
                ).map(|lines| {
                    let mut apply = apply::Apply::new(lines);
                    apply.add_hunks(diff);
                    apply.apply()
                });
                sender.try_send(applied).unwrap();
            });
            receivers.push(receiver);
        }
        receivers.into_iter()
            .map(|rcv| async move { rcv.recv().await.unwrap() })
            .collect()
    }

    /// Destroy the blocks and move them to the resolution cache.
    pub(crate) fn cache_blocks(&mut self) {
        for file in self.files.iter_mut() {
            if let Some(blocks) = file.blocks.take() {
                file.resolution_cache.add_blocks(blocks);
            }
        }
    }

    /// Return true if AI was used when applying this commit. Panics if the commit is not
    /// applied.
    pub(crate) fn was_ai_used(&self) -> bool {
        self.files.iter().any(|file| file.blocks.as_ref().unwrap().was_ai_used())
    }

    /// Return Some(true) if the commit was a clean cherry-pick, Some(false) if it was not.
    /// Return None if the commit is not applied.
    pub(crate) fn is_clean_cherry_pick(&self) -> Option<bool> {
        for file in self.files.iter() {
            let Some(blocks) = file.blocks.as_ref() else { return None };
            if blocks.iter().any(|block|
                block.is_unapplied() ||
                (block.is_change() && block.as_change().fuzz != apply::AppliedFuzz::None)
            ) {
                return Some(false);
            }
        }
        Some(true)
    }

    /// Return the summary of modifications to the upstream patch. This is used to construct
    /// the Conflicts: section in the commit log. Panics if the commit is not applied or is
    /// not fully resolved (AI or edit in progress or failed).
    pub(crate) fn conflicts(&self) -> Vec<Conflict> {
        let mut result = vec![];
        for file in self.files.iter() {
            let mut conflict = Conflict {
                path: Vec::new(),
                problems: Vec::new(),
            };
            let blocks = file.blocks.as_ref().unwrap();
            let modified: usize = blocks.count_modified();
            if modified > 0 {
                conflict.problems.push(format_bytes!(
                    b"{} hunk{} modified",
                    modified,
                    if modified > 1 { b"s".as_slice() } else { b"".as_slice() },
                ));
            }
            let missing = blocks.iter()
                .filter(|block| block.is_unapplied())
                .count();
            if missing > 0 {
                conflict.problems.push(format_bytes!(
                    b"{} hunk{} omitted",
                    missing,
                    if missing > 1 { b"s".as_slice() } else { b"".as_slice() },
                ));
            }
            if blocks.old_lines.path != file.upstream_metadata.name {
                conflict.problems.push(format_bytes!(
                    b"different file name: {}",
                    blocks.old_lines.path,
                ));
            }
            if matches!(&file.upstream_metadata.op, diff_meta::Op::Removed) &&
                blocks.iter_bytes().next().is_some()
            {
                // There was some content in the file. We did not delete it.
                conflict.problems.push(b"the file was not deleted".to_vec());
            }
            if !conflict.problems.is_empty() {
                conflict.path.extend_from_slice(&file.upstream_metadata.name);
                result.push(conflict);
            }
        }
        result
    }

    /// Returns true if we have metadata for this Commit. If we have, we can do the
    /// operations that require commit history, e.g. finding suggested commits.
    pub fn has_metadata(&self) -> bool {
        self.metadata.is_some()
    }

    /// Returns the commit description. Panics if there are no metadata for this Commit.
    pub fn description(&self) -> Rc<git::Description> {
        self.metadata.as_ref().unwrap().description.clone()
    }

    /// Get the name suitable for displaying to the user. Indicates file rename, deletion or
    /// addition. Indicates difference between upstream and downstream name.
    pub fn format_filename(&self, file_index: usize) -> Formatted {
        self.files[file_index].format_filename()
    }

    /// Get the fork point. This will block (potentially for seconds) if the fork point
    /// hasn't been calculated yet. Panics if there are no metadata for this Commit.
    pub(crate) fn fork_point(&self) -> &ForkPoint {
        &self.metadata.as_ref().unwrap().fork_point
    }

    /// Get the file history. Will spawn a thread to compute the history in the background
    /// if it has not been computed yet. Panics if there are no metadata for this Commit.
    pub(crate) fn file_history<'a>(&'a self, file: &'a CommitFile, upstream_repo: &Git)
        -> &'a history::FileHistory
    {
        file.file_history.get_or_init(|| {
            history::FileHistory::new(
                upstream_repo.clone(),
                self.description().id.clone(),
                file.upstream_metadata.name.clone(),
                self.fork_point().clone(),
            )
        })
    }
}

impl CommitMetadata {
    pub(crate) fn new(
        description: Rc<git::Description>,
        upstream_repo: Git,
        downstream_repo: Git,
    ) -> Self {
        let fork_point = ForkPoint::new(
            downstream_repo,
            upstream_repo,
            b"HEAD".to_vec(),
            description.id.clone(),
        );
        Self {
            description,
            fork_point,
        }
    }
}

impl CommitFile {
    /// Get the name suitable for displaying to the user. Indicates file rename, deletion or
    /// addition. Indicates difference between upstream and downstream name. Panics if the
    /// commit this file belongs to is not applied.
    fn format_filename(&self) -> Formatted {
        let mut fmt = Formatted::default();
        match &self.upstream_metadata.op {
            diff_meta::Op::Added => fmt.push("🆕 "),
            diff_meta::Op::Removed => fmt.push("🗑 "),
            _ => ()
        }
        let src = &self.blocks.as_ref().unwrap().old_lines.path;
        fmt.push(src);
        if *src != self.upstream_metadata.name {
            fmt.push(" ");
            fmt.push_styles(
                &self.upstream_metadata.name,
                [Style::Strikethrough, Style::Color(0x777777)],
            );
        }
        if let diff_meta::Op::Modified { new_name, .. } = &self.upstream_metadata.op {
            if let Some(new_name) = new_name {
                fmt.push(" → ");
                fmt.push(new_name);
            }
        }
        fmt
    }

    pub(crate) fn pop_cache(&mut self, change: &apply::AppliedChange)
        -> Option<ResolutionCacheEntry>
    {
        let old_lines = self.blocks.as_ref().unwrap().old_lines
            .line_slice(change.range.clone());
        self.resolution_cache.pop(old_lines, &change.hunk)
    }
}

impl FilePicker {
    /// Get the appropriate file to which the diff should be applied.
    /// Note this method may be run in a separate thread.
    fn pick_lines(
        self,
        filesystem: SharedVirtualFilesystem,
        upstream_metadata: diff_meta::Metadata,
        upstream_repo: Git,
        commit_id: Option<Vec<u8>>,
        fork_point: Option<ForkPoint>,
    ) -> Result<TypedLines, String> {
        match self {
            Self::FromUpstream(picker) => picker.pick_lines(
                filesystem,
                upstream_metadata,
                upstream_repo,
                commit_id.unwrap(),
                fork_point.unwrap(),
            ),
            Self::FromConflict(picker) => picker.pick_lines(
                filesystem,
                upstream_metadata,
            ),
            Self::FromMergetool(picker) => picker.pick_lines(filesystem),
        }
    }
}

impl FileFromUpstream {
    pub(crate) fn new() -> Self {
        Self {}
    }

    fn pick_lines(
        self,
        filesystem: SharedVirtualFilesystem,
        upstream_metadata: diff_meta::Metadata,
        upstream_repo: Git,
        commit_id: Vec<u8>,
        fork_point: ForkPoint,
    ) -> Result<TypedLines, String> {
        if
            matches!(
                &upstream_metadata.op,
                diff_meta::Op::Modified { new_mode, .. }
                    if new_mode.is_set() && !new_mode.is_regular()
            ) || (
                upstream_metadata.mode.is_set() && !upstream_metadata.mode.is_regular()
            )
        {
            return Err(format!("Patches touching symlinks and other special files are not supported yet."));
        }
        // First, try opening the destination file. If it exists, use it. If this is an
        // addition, we may already have the file downstream. If this is a rename, we may
        // already have the file renamed. In all cases, we prefer the destination path.
        // A corner case: if the commit swaps two files (renames a -> b and b -> a), we'll
        // miss the renames. But it seems git doesn't present such situation as two
        // renames.
        let mut lines = None;
        if let Some(path) = upstream_metadata.dest_name() {
            lines = filesystem.get_file(path)?;
        }
        // Second, try opening the source file.
        if lines.is_none() {
            if let Some(path) = upstream_metadata.src_name() {
                lines = filesystem.get_file(path)?;
            }
        }
        // Third, detect different upstream and downstream names caused by a different
        // commit history. Skip the detection for newly added files (imagine a case that
        // a file is renamed and in the next commit a new file is created with the
        // previous name; we wouldn't want to try applying the added file to the renamed
        // file).
        if lines.is_none() && !matches!(&upstream_metadata.op, diff_meta::Op::Added) {
            // Look if the file got renamed downstream.
            let path = upstream_metadata.src_name().unwrap();
            lines = filesystem.get_renamed_file(
                &path,
                fork_point.get(),
            )?;
            // Look if the file got renamed upstream.
            if lines.is_none() {
                for new_path in upstream_repo
                    .previous_paths(
                        &commit_id,
                        fork_point.get(),
                        path,
                    )
                    .map_err(|err| format!(
                        "Error getting renames from the source repository: {err}"
                    ))?
                {
                    lines = filesystem.get_file(&new_path)?;
                    if lines.is_some() {
                        break;
                    }
                    // Maybe this file got also renamed downstream?
                    lines = filesystem.get_renamed_file(
                        &new_path,
                        fork_point.get(),
                    )?;
                    if lines.is_some() {
                        break;
                    }
                }
            }
        }
        // If nothing succeeds, use an empty file. Set its name to the source name,
        // except for file additions, where it will be the destination name.
        let lines = lines.unwrap_or_else(||
            TypedLines::from_vec_and_name(Vec::new(), &upstream_metadata.name)
        );
        Ok(lines)
    }
}

impl FileFromConflict {
    pub(crate) fn new(blob_sha: Vec<u8>) -> Self {
        Self {
            blob_sha,
        }
    }

    fn pick_lines(
        self,
        filesystem: SharedVirtualFilesystem,
        upstream_metadata: diff_meta::Metadata,
    ) -> Result<TypedLines, String> {
        Ok(TypedLines::from_vec_and_name(
            filesystem.downstream_repo.blob(&self.blob_sha).map_err(|e| e.to_string())?,
            &upstream_metadata.name,
        ))
    }
}

impl FileFromMergetool {
    pub(crate) fn new(local_path: &[u8], merged_path: &[u8])
        -> Self
    {
        Self {
            local_path: local_path.to_vec(),
            merged_path: merged_path.to_vec(),
        }
    }

    fn pick_lines(self, filesystem: SharedVirtualFilesystem) -> Result<TypedLines, String> {
        Ok(filesystem.get_file(&self.local_path)?
            .map(|mut lines| {
                lines.set_path(&self.merged_path);
                lines
            })
            .unwrap_or_else(||
                TypedLines::from_vec_and_name(Vec::new(), &self.merged_path)
            )
        )
    }
}
