use bstr::{BStr, BString, ByteSlice};

use crate::handshake::{refs::parse::Error, Ref};

impl From<InternalRef> for Ref {
    fn from(v: InternalRef) -> Self {
        match v {
            InternalRef::Symbolic {
                path,
                target: Some(target),
                tag,
                object,
            } => Ref::Symbolic {
                full_ref_name: path,
                target,
                tag,
                object,
            },
            InternalRef::Symbolic {
                path,
                target: None,
                tag: None,
                object,
            } => Ref::Direct {
                full_ref_name: path,
                object,
            },
            InternalRef::Symbolic {
                path,
                target: None,
                tag: Some(tag),
                object,
            } => Ref::Peeled {
                full_ref_name: path,
                tag,
                object,
            },
            InternalRef::Peeled { path, tag, object } => Ref::Peeled {
                full_ref_name: path,
                tag,
                object,
            },
            InternalRef::Direct { path, object } => Ref::Direct {
                full_ref_name: path,
                object,
            },
            InternalRef::SymbolicForLookup { .. } => {
                unreachable!("this case should have been removed during processing")
            }
        }
    }
}

#[cfg_attr(test, derive(PartialEq, Eq, Debug, Clone))]
pub(crate) enum InternalRef {
    /// A ref pointing to a `tag` object, which in turns points to an `object`, usually a commit
    Peeled {
        path: BString,
        tag: gix_hash::ObjectId,
        object: gix_hash::ObjectId,
    },
    /// A ref pointing to a commit object
    Direct { path: BString, object: gix_hash::ObjectId },
    /// A symbolic ref pointing to `target` ref, which in turn points to an `object`
    Symbolic {
        path: BString,
        /// It is `None` if the target is unreachable as it points to another namespace than the one is currently set
        /// on the server (i.e. based on the repository at hand or the user performing the operation).
        ///
        /// The latter is more of an edge case, please [this issue][#205] for details.
        target: Option<BString>,
        tag: Option<gix_hash::ObjectId>,
        object: gix_hash::ObjectId,
    },
    /// extracted from V1 capabilities, which contain some important symbolic refs along with their targets
    /// These don't contain the Id
    SymbolicForLookup { path: BString, target: Option<BString> },
}

impl InternalRef {
    fn unpack_direct(self) -> Option<(BString, gix_hash::ObjectId)> {
        match self {
            InternalRef::Direct { path, object } => Some((path, object)),
            _ => None,
        }
    }
    fn lookup_symbol_has_path(&self, predicate_path: &BStr) -> bool {
        matches!(self, InternalRef::SymbolicForLookup { path, .. } if path == predicate_path)
    }
}

pub(crate) fn from_capabilities<'a>(
    capabilities: impl Iterator<Item = gix_transport::client::capabilities::Capability<'a>>,
) -> Result<Vec<InternalRef>, Error> {
    let mut out_refs = Vec::new();
    let symref_values = capabilities.filter_map(|c| {
        if c.name() == b"symref".as_bstr() {
            c.value().map(ToOwned::to_owned)
        } else {
            None
        }
    });
    for symref in symref_values {
        let (left, right) = symref.split_at(symref.find_byte(b':').ok_or_else(|| Error::MalformedSymref {
            symref: symref.to_owned(),
        })?);
        if left.is_empty() || right.is_empty() {
            return Err(Error::MalformedSymref {
                symref: symref.to_owned(),
            });
        }
        out_refs.push(InternalRef::SymbolicForLookup {
            path: left.into(),
            target: match &right[1..] {
                b"(null)" => None,
                name => Some(name.into()),
            },
        })
    }
    Ok(out_refs)
}

pub(in crate::handshake::refs) fn parse_v1(
    num_initial_out_refs: usize,
    out_refs: &mut Vec<InternalRef>,
    line: &BStr,
) -> Result<(), Error> {
    let trimmed = line.trim_end();
    let (hex_hash, path) = trimmed.split_at(
        trimmed
            .find(b" ")
            .ok_or_else(|| Error::MalformedV1RefLine(trimmed.to_owned().into()))?,
    );
    let path = &path[1..];
    if path.is_empty() {
        return Err(Error::MalformedV1RefLine(trimmed.to_owned().into()));
    }
    match path.strip_suffix(b"^{}") {
        Some(stripped) => {
            if hex_hash.iter().all(|b| *b == b'0') && stripped == b"capabilities" {
                // this is a special dummy-ref just for the sake of getting capabilities across in a repo that is empty.
                return Ok(());
            }
            let (previous_path, tag) =
                out_refs
                    .pop()
                    .and_then(InternalRef::unpack_direct)
                    .ok_or(Error::InvariantViolation {
                        message: "Expecting peeled refs to be preceded by direct refs",
                    })?;
            if previous_path != stripped {
                return Err(Error::InvariantViolation {
                    message: "Expecting peeled refs to have the same base path as the previous, unpeeled one",
                });
            }
            out_refs.push(InternalRef::Peeled {
                path: previous_path,
                tag,
                object: gix_hash::ObjectId::from_hex(hex_hash.as_bytes())?,
            });
        }
        None => {
            let object = gix_hash::ObjectId::from_hex(hex_hash.as_bytes())?;
            match out_refs
                .iter()
                .take(num_initial_out_refs)
                .position(|r| r.lookup_symbol_has_path(path.into()))
            {
                Some(position) => match out_refs.swap_remove(position) {
                    InternalRef::SymbolicForLookup { path: _, target } => out_refs.push(InternalRef::Symbolic {
                        path: path.into(),
                        tag: None, // TODO: figure out how annotated tags work here.
                        object,
                        target,
                    }),
                    _ => unreachable!("Bug in lookup_symbol_has_path - must return lookup symbols"),
                },
                None => out_refs.push(InternalRef::Direct {
                    object,
                    path: path.into(),
                }),
            };
        }
    }
    Ok(())
}

pub(in crate::handshake::refs) fn parse_v2(line: &BStr) -> Result<Ref, Error> {
    let trimmed = line.trim_end();
    let mut tokens = trimmed.splitn(4, |b| *b == b' ');
    match (tokens.next(), tokens.next()) {
        (Some(hex_hash), Some(path)) => {
            let id = if hex_hash == b"unborn" {
                None
            } else {
                Some(gix_hash::ObjectId::from_hex(hex_hash.as_bytes())?)
            };
            if path.is_empty() {
                return Err(Error::MalformedV2RefLine(trimmed.to_owned().into()));
            }
            let mut symref_target = None;
            let mut peeled = None;
            for attribute in tokens.by_ref().take(2) {
                let mut tokens = attribute.splitn(2, |b| *b == b':');
                match (tokens.next(), tokens.next()) {
                    (Some(attribute), Some(value)) => {
                        if value.is_empty() {
                            return Err(Error::MalformedV2RefLine(trimmed.to_owned().into()));
                        }
                        match attribute {
                            b"peeled" => {
                                peeled = Some(gix_hash::ObjectId::from_hex(value.as_bytes())?);
                            }
                            b"symref-target" => {
                                symref_target = Some(value);
                            }
                            _ => {
                                return Err(Error::UnknownAttribute {
                                    attribute: attribute.to_owned().into(),
                                    line: trimmed.to_owned().into(),
                                })
                            }
                        }
                    }
                    _ => return Err(Error::MalformedV2RefLine(trimmed.to_owned().into())),
                }
            }
            if tokens.next().is_some() {
                return Err(Error::MalformedV2RefLine(trimmed.to_owned().into()));
            }
            Ok(match (symref_target, peeled) {
                (Some(target_name), peeled) => match target_name {
                    b"(null)" => match peeled {
                        None => Ref::Direct {
                            full_ref_name: path.into(),
                            object: id.ok_or(Error::InvariantViolation {
                                message: "got 'unborn' while (null) was a symref target",
                            })?,
                        },
                        Some(peeled) => Ref::Peeled {
                            full_ref_name: path.into(),
                            object: peeled,
                            tag: id.ok_or(Error::InvariantViolation {
                                message: "got 'unborn' while (null) was a symref target",
                            })?,
                        },
                    },
                    name => match id {
                        Some(id) => Ref::Symbolic {
                            full_ref_name: path.into(),
                            tag: peeled.map(|_| id),
                            object: peeled.unwrap_or(id),
                            target: name.into(),
                        },
                        None => Ref::Unborn {
                            full_ref_name: path.into(),
                            target: name.into(),
                        },
                    },
                },
                (None, Some(peeled)) => Ref::Peeled {
                    full_ref_name: path.into(),
                    object: peeled,
                    tag: id.ok_or(Error::InvariantViolation {
                        message: "got 'unborn' as tag target",
                    })?,
                },
                (None, None) => Ref::Direct {
                    object: id.ok_or(Error::InvariantViolation {
                        message: "got 'unborn' as object name of direct reference",
                    })?,
                    full_ref_name: path.into(),
                },
            })
        }
        _ => Err(Error::MalformedV2RefLine(trimmed.to_owned().into())),
    }
}
