use bstr::ByteSlice;
use std::{fs, io, str::FromStr};
use std::ffi::OsStr;
use std::os::unix::{ffi::OsStrExt, fs::MetadataExt, fs::PermissionsExt};
use super::{diff, lines::Lines, lines::TypedLines};

#[derive(Clone, Copy, derive_more::Debug, Default, PartialEq, Eq)]
pub struct FileMode(#[debug("0o{_0:o}")] u32);

#[derive(Clone, Debug)]
pub enum Op {
    /// The file is newly added.
    Added,
    /// The file is being deleted.
    Removed,
    /// The file is modified. Either the content, or the mode, or the name (it's renamed),
    /// or any combination of these.
    Modified {
        /// The new name. If None, the file is not renamed.
        new_name: Option<Vec<u8>>,
        /// The new mode. If !is_set(), the mode is not changed.
        new_mode: FileMode,
    },
}

#[derive(Clone, derive_more::Debug)]
pub struct Metadata {
    /// The file name. In case of renames, this is the old name.
    #[debug("{:?}", name.as_bstr())]
    pub name: Vec<u8>,
    /// File mode (file type and permissions). If is_empty(), the old mode is not specified;
    /// this generally means it's unchanged by the patch. In case of mode changes, this is
    /// the old mode.
    pub mode: FileMode,
    /// The operation that happened.
    pub op: Op,
}

#[derive(Debug)]
pub struct Data {
    /// The source (old) file.
    pub src: TypedLines,
    /// The destination (new) file.
    pub(crate) dest: Lines,
}

#[derive(derive_more::Debug)]
pub struct FileChange {
    pub metadata: Metadata,
    pub data: Data,
    #[debug(ignore)]
    _private: (),
}

impl FileMode {
    pub(crate) fn from_ascii(bytes: &[u8]) -> Option<Self> {
        // Instead of going through a string, we should use u32::from_ascii_radix once
        // it's stable.
        std::str::from_utf8(bytes)
            .ok()
            .and_then(|s| s.parse().ok())
    }

    pub(crate) fn from_file(name: &OsStr) -> io::Result<Self> {
        Ok(Self(fs::metadata(name)?.mode()))
    }

    pub fn is_set(&self) -> bool {
        self.0 != 0
    }

    pub fn is_regular(&self) -> bool {
        (self.0 & 0o170000) == 0o100000
    }

    pub fn is_symlink(&self) -> bool {
        (self.0 & 0o170000) == 0o120000
    }

    pub fn to_permissions(&self) -> Option<fs::Permissions> {
        if self.is_set() {
            Some(fs::Permissions::from_mode(self.0 & 0o7777))
        } else {
            None
        }
    }
}

impl FromStr for FileMode {
    type Err = std::num::ParseIntError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        u32::from_str_radix(s, 8).map(|u| Self(u))
    }
}

impl Default for Op {
    fn default() -> Self {
        Op::Modified {
            new_name: None,
            new_mode: FileMode::default(),
        }
    }
}

impl Metadata {
    pub(crate) fn new(name: Vec<u8>, mode: FileMode, op: Op) -> Self {
        Self {
            name,
            mode,
            op,
        }
    }

    /// Get the source name or None when called on an added file.
    pub fn src_name(&self) -> Option<&[u8]> {
        Some(match &self.op {
            Op::Added => return None,
            Op::Removed => &self.name,
            Op::Modified { .. } => &self.name,
        })
    }

    /// Get the destination name or None when called on a removed file.
    pub fn dest_name(&self) -> Option<&[u8]> {
        Some(match &self.op {
            Op::Added => &self.name,
            Op::Removed => return None,
            Op::Modified { new_name, .. } => new_name.as_ref().unwrap_or(&self.name),
        })
    }
}

impl Data {
    /// Calculate the diff.
    pub fn diff(&self) -> Vec<diff::Hunk> {
        diff::diff_lines(&self.src, &self.dest)
    }
}

impl FileChange {
    pub(crate) fn new(metadata: Metadata, mut src: TypedLines, dest: Lines) -> Self {
        src.annotate();
        Self {
            metadata,
            data: Data {
                src,
                dest,
            },
            _private: (),
        }
    }

    /// Create the metadata by loading content of files at paths `a` (old) and `b` (new).
    /// The file name recorded in the metadata will be `fake_dest`.
    pub fn from_files(a: &OsStr, b: &OsStr, fake_dest: &OsStr) -> io::Result<Self> {
        let mode = FileMode::from_file(a)?;
        let new_mode = FileMode::from_file(b)?;
        let metadata = Metadata::new(
            fake_dest.as_bytes().to_owned(),
            mode,
            Op::Modified {
                new_name: None,
                new_mode: if mode == new_mode {
                    FileMode::default()
                } else {
                    new_mode
                }
            },
        );
        Ok(Self::new(
            metadata,
            TypedLines::from_file(a.as_bytes())?,
            TypedLines::from_file(b.as_bytes())?.into(),
        ))
    }
}
