#![allow(clippy::borrow_interior_mutable_const)]
/// Logic around handling post type discovery.
///
/// PTD (a.k.a [post type discovery](https://indieweb.org/ptd)) is a means of resolving
/// the semantically relevant post type of a provided [item][microformats::types::Item].
/// By default, everything falls back to a [Entry][microformats::types::KnownClass::Entry].
use crate::mf2::types;
use regex::Regex;
use std::{
    collections::{HashMap, HashSet},
    iter::FromIterator,
    str::FromStr,
    sync::OnceLock,
};
use url::Url;

fn is_valid_url(value: &str) -> bool {
    Url::parse(value).is_ok()
}

fn normalize_for_comparison(s: &str) -> String {
    static RE_WHITESPACE: OnceLock<Regex> = OnceLock::new();
    let re = RE_WHITESPACE
        .get_or_init(|| Regex::new(r"\s+").expect("Failed to compile whitespace regex"));
    re.replace_all(s.trim(), " ").to_string()
}

/// A canonical list of the recognized post types.
///
/// A full list of them can be found at <https://indieweb.org/posts#Types_of_Posts>
// FIXME: Move 'experimental' types into a separate enum.
#[derive(
    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum Type {
    /// <https://indieweb.org/like>
    Like,
    /// <https://indieweb.org/bookmark>
    Bookmark,
    /// <https://indieweb.org/reply>
    Reply,
    #[cfg(feature = "reaction")]
    /// <https://indieweb.org/reacji>
    Reaction,
    /// <https://indieweb.org/repost>
    Repost,
    /// <https://indieweb.org/thread>
    #[serde(rename = "thread")]
    Thread,
    /// <https://indieweb.org/note>
    #[default]
    Note,
    /// <https://indieweb.org/article>
    Article,
    /// <https://indieweb.org/photo>
    Photo,
    /// <https://indieweb.org/screenshot>
    #[serde(rename = "screenshot")]
    Screenshot,
    /// <https://indieweb.org/video>
    Video,
    /// <https://indieweb.org/audio>
    Audio,
    /// A catch-all type for more than one of a [Type::Photo], [Type::Audio] or [Type::Video] in one post.
    Media,
    /// <https://indieweb.org/poll>
    #[serde(rename = "poll")]
    Poll,
    /// <https://indieweb.org/quote>
    Quotation,
    /// <https://indieweb.org/gameplay>
    #[serde(rename = "gameplay")]
    GamePlay,
    /// <https://indieweb.org/rsvp>
    #[serde(rename = "rsvp")]
    RSVP,
    /// <https://indieweb.org/checkin>
    #[serde(rename = "checkin")]
    CheckIn,
    /// <https://indieweb.org/listen>
    Listen,
    /// <https://indieweb.org/watch>
    Watch,
    /// <https://indieweb.org/review>
    Review,
    /// <https://indieweb.org/read>
    Read,
    /// <https://indieweb.org/jam>
    Jam,
    /// <https://indieweb.org/follow>
    Follow,
    /// <https://indieweb.org/event>
    Event,
    /// <https://indieweb.org/issue>
    Issue,
    /// <https://indieweb.org/venue>
    Venue,
    /// <https://indieweb.org/collection>
    Collection,
    /// <https://indieweb.org/presentation>
    Presentation,
    /// <https://indieweb.org/exericse>
    Exercise,
    /// <https://indieweb.org/recipe>
    Recipe,
    /// <https://indieweb.org/wish>
    Wish,
    /// <https://indieweb.org/edit>
    Edit,
    /// <https://indieweb.org/sleep>
    Sleep,
    /// <https://indieweb.org/session>
    Session,
    /// <https://indieweb.org/snark>
    Snark,
    /// <https://indieweb.org/donation>
    Donation,
    /// <https://indieweb.org/want>
    Want,
    /// <https://indieweb.org/mention>
    Mention,
    /// <https://indieweb.org/invitation>
    Invite,
    /// An unknown and unrecognized post type.
    Other(String),
}

impl Type {
    const REACTIONS: [Self; 13] = [
        Type::Reply,
        Type::Like,
        Type::RSVP,
        Type::Reaction,
        Type::Review,
        Type::Bookmark,
        Type::Repost,
        Type::Quotation,
        Type::Issue,
        Type::Edit,
        Type::Follow,
        Type::Listen,
        Type::Invite,
    ];

    /// Determines if this [`Type`] is that of a reaction.
    pub fn is_reaction(&self) -> bool {
        Self::REACTIONS.contains(self)
    }
}

impl std::fmt::Display for Type {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let type_str = if let Self::Other(s) = self {
            s.to_string()
        } else {
            serde_json::to_value(self)
                .map(|v| v.to_string().trim_matches('"').to_string())
                .unwrap_or_else(|_| "other".to_string())
        };

        f.write_str(&type_str)
    }
}

impl FromStr for Type {
    type Err = std::convert::Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(serde_json::from_str(&format!("\"{}\"", s))
            .unwrap_or_else(|_| Type::Other(s.to_string())))
    }
}

#[test]
fn type_to_string() {
    assert_eq!(Type::Note.to_string(), "note");
    assert_eq!(Type::Mention.to_string(), "mention");
    assert_eq!(Type::RSVP.to_string(), "rsvp");
    assert_eq!(Type::CheckIn.to_string(), "checkin");
    assert_eq!(Type::GamePlay.to_string(), "gameplay");
    assert_eq!(Type::Other("magic".to_string()).to_string(), "magic");
}

/// Represents the potential forms of defining a post type. The similar form
/// as represented by a single string is the one conventionally used. The
/// expanded form is one that's being experimented on to allow for the definition
/// of the constraints that a type can set.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(untagged, rename_all = "kebab-case")]
pub enum PostType {
    /// Represents a simpler (textual) form of a post type.
    Simple(Type),

    /// Represents an expanded way to describe a post type.
    Expanded {
        /// The presentational name of the post type.
        name: String,

        /// The known post type being expanded.
        #[serde(rename = "type")]
        kind: Type,

        /// The container type represented as a [microformats::types::Class][].
        #[serde(default = "default_class")]
        h: microformats::types::Class,

        /// Recognized properties for this post type.
        #[serde(default)]
        properties: Vec<String>,

        /// Properties for this post type that are required for it to be defined as this post type.
        #[serde(default)]
        required_properties: Vec<String>,
    },
}

fn default_class() -> types::Class {
    types::Class::Known(types::KnownClass::Entry)
}

impl From<PostType> for Type {
    fn from(post_type: PostType) -> Type {
        match post_type {
            PostType::Simple(kind) => kind,
            PostType::Expanded { kind, .. } => kind,
        }
    }
}

impl From<&PostType> for Type {
    fn from(post_type: &PostType) -> Type {
        match post_type {
            PostType::Simple(kind) => kind.clone(),
            PostType::Expanded { kind, .. } => kind.clone(),
        }
    }
}

impl PartialEq for PostType {
    fn eq(&self, other: &Self) -> bool {
        let ltype: Type = self.into();
        let rtype: Type = other.into();

        ltype == rtype
    }
}

impl PostType {
    /// Provides a human-friendly descriptor of this post type.
    pub fn name(&self) -> String {
        match self {
            Self::Simple(simple_type) => simple_type.to_string(),
            Self::Expanded { name, .. } => name.to_string(),
        }
    }

    /// Provides the actual type represented.
    pub fn kind(&self) -> String {
        match self {
            Self::Simple(simple_type) => simple_type.to_string(),
            Self::Expanded { kind, .. } => kind.to_string(),
        }
    }
}

#[test]
fn post_type_name() {
    assert_eq!(PostType::Simple(Type::Note).name(), "note".to_string());
    assert_eq!(PostType::Simple(Type::RSVP).name(), "rsvp".to_string());
}

fn properties_from_type() -> HashMap<String, Type> {
    HashMap::from_iter(
        vec![
            ("like-of".to_owned(), Type::Like),
            ("in-reply-to".to_owned(), Type::Reply),
            ("bookmark-of".to_owned(), Type::Bookmark),
            ("repost-of".to_owned(), Type::Repost),
            ("quotation-of".to_owned(), Type::Quotation),
            ("gameplay-of".to_owned(), Type::GamePlay),
            ("follow-of".to_owned(), Type::Follow),
            ("jam-of".to_owned(), Type::Jam),
            ("listen-of".to_owned(), Type::Listen),
            ("rsvp".to_owned(), Type::RSVP),
            ("photo".to_owned(), Type::Photo),
            ("screenshot-of".to_owned(), Type::Screenshot),
            ("video".to_owned(), Type::Video),
            ("audio".to_owned(), Type::Audio),
            ("checkin".to_owned(), Type::CheckIn),
            ("read-of".to_owned(), Type::Read),
            ("media".to_owned(), Type::Media),
            ("mention-of".to_owned(), Type::Mention),
            ("poll-of".to_owned(), Type::Poll),
            ("thread-of".to_owned(), Type::Thread),
        ]
        .iter()
        .cloned(),
    )
}

static REPLY_CONTEXT_PROPERTIES: [&str; 13] = [
    "in-reply-to",
    "like-of",
    "bookmark-of",
    "repost-of",
    "quotation-of",
    "follow-of",
    "listen-of",
    "gameplay-of",
    "mention-of",
    "rsvp",
    "read-of",
    "checkin",
    "thread-of",
];

/// Determines if the provided property is one that implies a contextual response.
///
/// This is mainly opinionated so please send PRs for property names you'd like to see here.
///
/// # Examples
/// ```
/// # use indieweb::algorithms::ptd::is_reply_context_property;
/// assert!(is_reply_context_property("read-of"), "'read-of' is a reaction to reading something");
/// assert!(!is_reply_context_property("content"), "'content' does not indicate a reaction to anything");
/// ```
pub fn is_reply_context_property(property_name: &str) -> bool {
    REPLY_CONTEXT_PROPERTIES.contains(&property_name)
}

/// Determines the type of reactionary post this is from the provided property names.
///
/// See [resolve_from_property_names] for more information.
pub fn resolve_reaction_property_name(property_names: &[&str]) -> Option<Type> {
    let hashed = REPLY_CONTEXT_PROPERTIES
        .iter()
        .map(|s| s.to_string())
        .collect::<HashSet<String>>();
    let got = property_names.iter().map(|s| s.to_string()).collect();
    let mut reaction_types = hashed.intersection(&got).cloned().collect::<Vec<_>>();
    reaction_types.sort();
    reaction_types.dedup();

    resolve_from_property_names(reaction_types)
        .filter(|v| !matches!(v, Type::Note) || !matches!(v, Type::Article))
        .or(Some(Type::Mention))
}

#[test]
fn resolve_reaction_property_name_test() {
    assert_eq!(
        resolve_reaction_property_name(&["content", "in-reply-to"]),
        Some(Type::Reply)
    );
    assert_eq!(
        resolve_reaction_property_name(&["url", "gameplay-of"]),
        Some(Type::GamePlay)
    );
}

// FIXME: Figure out how to consider RSVPs.
// FIXME: Should reacjis be different here.
/// Determines the property name to use for publishing for a post type.
///
/// This is useful for semantically organizing things like Webmentions
/// or items in a collection of posts (or feed).
pub fn type_to_reaction_property_name(t: Type) -> String {
    if t == Type::Reply {
        "comment".to_string()
    } else if t.is_reaction() {
        t.to_string()
    } else {
        "mention".to_string()
    }
}

#[test]
fn type_to_reaction_property_name_test() {
    assert_eq!(type_to_reaction_property_name(Type::Like), "like");
    #[cfg(feature = "reaction")]
    assert_eq!(type_to_reaction_property_name(Type::Reaction), "reaction");
    assert_eq!(type_to_reaction_property_name(Type::Reply), "comment");
    assert_eq!(type_to_reaction_property_name(Type::Note), "mention");
    assert_eq!(type_to_reaction_property_name(Type::Article), "mention");
    assert_eq!(
        type_to_reaction_property_name(Type::Other("grr".to_string())),
        "mention"
    );
}

const PTD_COMPLAINT_CLASSES: [types::Class; 3] = [
    types::Class::Known(types::KnownClass::Entry),
    types::Class::Known(types::KnownClass::Cite),
    types::Class::Known(types::KnownClass::Review),
];

fn get_first_url_value(item: &types::Item, property: &str) -> Option<String> {
    item.properties
        .get(property)
        .and_then(|values| values.first())
        .and_then(|v| match v {
            types::PropertyValue::Url(u) => Some(u.to_string()),
            types::PropertyValue::Plain(s) => Some(s.to_string()),
            _ => None,
        })
        .filter(|s| is_valid_url(s))
}

fn get_first_text_value(item: &types::Item, property: &str) -> Option<String> {
    item.properties
        .get(property)
        .and_then(|values| values.first())
        .and_then(|v| match v {
            types::PropertyValue::Plain(s) => Some(s.to_string()),
            types::PropertyValue::Fragment(f) => Some(f.value.clone()),
            _ => None,
        })
        .filter(|s| !s.trim().is_empty())
}

fn has_valid_rsvp_value(item: &types::Item) -> bool {
    get_first_text_value(item, "rsvp")
        .map(|v| {
            matches!(
                v.to_lowercase().as_str(),
                "yes" | "no" | "maybe" | "interested"
            )
        })
        .unwrap_or(false)
}

fn detect_note_vs_article(item: &types::Item) -> Type {
    let content =
        get_first_text_value(item, "content").or_else(|| get_first_text_value(item, "summary"));

    let name = get_first_text_value(item, "name");

    match (name, content) {
        (None, _) => Type::Note,
        (Some(n), _) if n.trim().is_empty() => Type::Note,
        (Some(name), Some(content)) => {
            let normalized_name = normalize_for_comparison(&name);
            let normalized_content = normalize_for_comparison(&content);

            if normalized_content.starts_with(&normalized_name) {
                Type::Note
            } else {
                Type::Article
            }
        }
        (Some(_), None) => Type::Article,
    }
}

pub fn resolve_from_object(item_mf2: types::Item) -> Option<Type> {
    if item_mf2.r#type == vec![types::Class::Known(types::KnownClass::Event)] {
        return Some(Type::Event);
    }

    if !PTD_COMPLAINT_CLASSES
        .iter()
        .any(|klass| item_mf2.r#type.contains(klass))
    {
        return None;
    }

    if has_valid_rsvp_value(&item_mf2) {
        return Some(Type::RSVP);
    }

    if get_first_url_value(&item_mf2, "in-reply-to").is_some() {
        #[cfg(feature = "reaction")]
        if has_reaction_emoji_as_content(item_mf2.clone()) {
            return Some(Type::Reaction);
        }
        return Some(Type::Reply);
    }

    if get_first_url_value(&item_mf2, "repost-of").is_some() {
        return Some(Type::Repost);
    }

    if get_first_url_value(&item_mf2, "like-of").is_some() {
        return Some(Type::Like);
    }

    if get_first_url_value(&item_mf2, "video").is_some() {
        return Some(Type::Video);
    }

    if get_first_url_value(&item_mf2, "photo").is_some() {
        return Some(Type::Photo);
    }

    if get_first_url_value(&item_mf2, "bookmark-of").is_some() {
        return Some(Type::Bookmark);
    }

    if get_first_url_value(&item_mf2, "quotation-of").is_some() {
        return Some(Type::Quotation);
    }

    if get_first_url_value(&item_mf2, "follow-of").is_some() {
        return Some(Type::Follow);
    }

    if get_first_url_value(&item_mf2, "listen-of").is_some() {
        return Some(Type::Listen);
    }

    if get_first_url_value(&item_mf2, "watch-of").is_some() {
        return Some(Type::Watch);
    }

    if get_first_url_value(&item_mf2, "read-of").is_some() {
        return Some(Type::Read);
    }

    if get_first_url_value(&item_mf2, "jam-of").is_some() {
        return Some(Type::Jam);
    }

    if get_first_url_value(&item_mf2, "gameplay-of").is_some() {
        return Some(Type::GamePlay);
    }

    if get_first_url_value(&item_mf2, "checkin").is_some() {
        return Some(Type::CheckIn);
    }

    if get_first_url_value(&item_mf2, "mention-of").is_some() {
        return Some(Type::Mention);
    }

    let has_content = get_first_text_value(&item_mf2, "content").is_some();
    let has_summary = get_first_text_value(&item_mf2, "summary").is_some();

    if !has_content && !has_summary {
        return Some(Type::Note);
    }

    Some(detect_note_vs_article(&item_mf2))
}

static RE_IS_ONLY_EMOJI_OR_PICTOGRAPH: OnceLock<Regex> = OnceLock::new();

fn has_emoji(text: &str) -> bool {
    RE_IS_ONLY_EMOJI_OR_PICTOGRAPH
        .get_or_init(|| {
            Regex::new(r#"^(\p{Extended_Pictographic}|\p{Emoji_Presentation})+$"#)
                .expect("Failed to compile emoji matching regex")
        })
        .is_match(text)
}

#[cfg(feature = "reaction")]
fn has_reaction_emoji_as_content<V>(into_item_mf2: V) -> bool
where
    V: TryInto<types::Item>,
{
    if let Ok(contents) = into_item_mf2
        .try_into()
        .map(|item: types::Item| item.content())
    {
        contents
            .unwrap_or_default()
            .into_iter()
            .any(|content_value| match content_value {
                types::PropertyValue::Plain(text) => has_emoji(text.as_str()),
                types::PropertyValue::Fragment(types::Fragment { value: text, .. }) => {
                    has_emoji(text.as_str())
                }
                types::PropertyValue::Item(item) => has_reaction_emoji_as_content(item),
                _ => false,
            })
    } else {
        false
    }
}

/// Determines a potential post type from a list of property names.
///
/// # Examples
/// ```
/// # use indieweb::algorithms::ptd::*;
/// assert_eq!(
///     resolve_from_property_names(vec!["like-of".into()]),
///     Some(Type::Like),
///     "'like-of' indicates a like post.");
///
/// assert_eq!(
///     resolve_from_property_names(vec!["content".into()]),
///     Some(Type::Note),
///     "Just 'content' is a note.");
/// ```
pub fn resolve_from_property_names(names: Vec<String>) -> Option<Type> {
    let has_content = names.contains(&"content".to_owned());
    let has_name = names.contains(&"name".to_string());

    let mut types: Vec<Type> = vec![];

    properties_from_type().iter().for_each(|(key, val)| {
        if names.contains(key) {
            types.push(val.clone());
        }
    });

    if has_name && has_content {
        types.push(Type::Article)
    } else if !has_name && has_content {
        types.push(Type::Note)
    }

    let first_type = types.first().cloned();

    combinatory_type(types.clone())
        .or(first_type)
        .or(Some(Type::Note))
}

/// Returns ALL applicable types for a post, not just the primary one.
/// Useful for composite posts like "reply with photo" or "RSVP with comment".
///
/// # Examples
/// ```
/// # use indieweb::algorithms::ptd::*;
/// let types = resolve_all_types(vec!["like-of".into(), "photo".into()]);
/// assert!(types.contains(&Type::Like), "contains like type");
/// assert!(types.contains(&Type::Photo), "contains photo type");
/// ```
pub fn resolve_all_types(names: Vec<String>) -> Vec<Type> {
    let has_content = names.contains(&"content".to_owned());
    let has_name = names.contains(&"name".to_string());

    let mut all_types: Vec<Type> = vec![];

    // Collect all matching types from property mappings
    properties_from_type().iter().for_each(|(key, val)| {
        if names.contains(key) {
            all_types.push(val.clone());
        }
    });

    // Add note/article detection
    if has_name && has_content {
        all_types.push(Type::Article);
    } else if !has_name && has_content {
        all_types.push(Type::Note);
    }

    // Order: reaction types first, then content types, then note/article
    let mut reactions: Vec<Type> = vec![];
    let mut content_types: Vec<Type> = vec![];
    let mut text_types: Vec<Type> = vec![];
    let mut other_types: Vec<Type> = vec![];

    for t in all_types {
        if t.is_reaction() {
            reactions.push(t);
        } else if matches!(
            t,
            Type::Photo | Type::Video | Type::Audio | Type::Media | Type::Screenshot | Type::Poll
        ) {
            content_types.push(t);
        } else if matches!(t, Type::Note | Type::Article) {
            text_types.push(t);
        } else {
            other_types.push(t);
        }
    }

    reactions.append(&mut content_types);
    reactions.append(&mut other_types);
    reactions.append(&mut text_types);

    // Default to Note if no types found
    if reactions.is_empty() {
        reactions.push(Type::Note);
    }

    reactions
}

fn combinatory_type(types: Vec<Type>) -> Option<Type> {
    [
        (Type::RSVP, vec![Type::Reply, Type::RSVP]),
        (Type::Photo, vec![Type::Photo, Type::Note]),
        (Type::Video, vec![Type::Video, Type::Photo, Type::Note]),
        (
            Type::Media,
            vec![Type::Audio, Type::Video, Type::Photo, Type::Note],
        ),
    ]
    .iter()
    .find_map(|(combined_type, expected_types)| {
        if expected_types
            .iter()
            .all(|post_type| types.contains(post_type))
        {
            Some(combined_type.to_owned())
        } else {
            None
        }
    })
}

#[test]
fn post_type_from_json() {
    assert_eq!(
        serde_json::from_str::<PostType>(
            r#"
                {
                    "name": "Note",
                    "type": "note"
                }
                "#
        )
        .ok(),
        Some(PostType::Expanded {
            name: "Note".to_string(),
            kind: Type::Note,
            h: default_class(),
            properties: Vec::default(),
            required_properties: Vec::default()
        })
    );

    assert_eq!(
        serde_json::from_str::<Vec<PostType>>(
            r#"
                [{
                    "name": "Note",
                    "type": "note"
                }, "like"]
                "#
        )
        .ok(),
        Some(vec![
            PostType::Expanded {
                name: "Note".to_string(),
                kind: Type::Note,
                h: default_class(),
                properties: Vec::default(),
                required_properties: Vec::default()
            },
            PostType::Simple(Type::Like)
        ])
    );

    assert_eq!(
        serde_json::from_str::<PostType>(r#""note""#).ok(),
        Some(PostType::Simple(Type::Note))
    );

    assert_eq!(
        serde_qs::from_str::<Type>("note").map_err(|e| e.to_string()),
        Ok(Type::Note)
    );

    #[derive(serde::Deserialize, PartialEq, Debug)]
    struct V {
        v: Vec<Type>,
    }

    assert_eq!(
        serde_qs::from_str::<V>("v[0]=note&v[1]=like").map_err(|e| e.to_string()),
        Ok(V {
            v: vec![Type::Note, Type::Like]
        })
    );
}

#[test]
fn type_from_str() {
    assert_eq!(Type::from_str("note"), Ok(Type::Note));
}

#[test]
fn post_type_partial_eq() {
    assert_eq!(
        PostType::Simple(Type::Like),
        PostType::Expanded {
            kind: Type::Like,
            name: "Like".to_string(),
            h: default_class(),
            properties: Vec::default(),
            required_properties: Vec::default()
        }
    );
}

#[test]
fn resolve_from_object_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "like-of": ["https://indieweb.org/like"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Like)
    );
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "in-reply-to": ["https://indieweb.org/rsvp"],
                    "rsvp": ["yes"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::RSVP)
    );
    #[cfg(feature = "reaction")]
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "in-reply-to": ["https://indieweb.org/rsvp"],
                    "content": "🚀"
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Reaction),
        "detected a single emoji reaction"
    );
    #[cfg(feature = "reaction")]
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "in-reply-to": ["https://indieweb.org/rsvp"],
                    "content": ["👋🏿"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Reaction),
        "detected a reaction with a skin tone modifier"
    );
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "in-reply-to": ["https://indieweb.org/rsvp"],
                    "content": ["hey there! 👋🏿"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Reply),
        "ignores if there's text included"
    );
}

#[test]
fn resolve_from_property_names_test() {
    assert_eq!(
        resolve_from_property_names(vec!["like-of".into()]),
        Some(Type::Like)
    );
}

#[test]
fn is_valid_url_test() {
    assert!(is_valid_url("https://example.com"));
    assert!(is_valid_url("http://example.com/path"));
    assert!(is_valid_url("https://example.com/path?query=value"));
    assert!(!is_valid_url("not a url"));
    assert!(!is_valid_url(""));
    assert!(!is_valid_url("example.com"));
}

#[test]
fn normalize_for_comparison_test() {
    assert_eq!(normalize_for_comparison("  hello  world  "), "hello world");
    assert_eq!(normalize_for_comparison("hello\t\nworld"), "hello world");
    assert_eq!(normalize_for_comparison("  trimmed  "), "trimmed");
    assert_eq!(normalize_for_comparison(""), "");
    assert_eq!(normalize_for_comparison("single"), "single");
}

#[test]
fn summary_fallback_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "summary": ["A summary without content"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "summary without name should be a note"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "name": ["Article Title"],
                    "summary": ["This is a summary"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Article),
        "summary with non-matching name should be an article"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "name": ["This is content"],
                    "content": ["This is content with more text"],
                    "summary": ["This is a summary"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "content takes precedence over summary"
    );
}

#[test]
fn article_vs_note_prefix_detection_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "name": ["My Title"],
                    "content": ["My Title and some more content"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "name is prefix of content -> note"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "name": ["Different Title"],
                    "content": ["This content doesn't match"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Article),
        "name is not prefix of content -> article"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "content": ["Just content, no name"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "no name -> note"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "name": [""],
                    "content": ["Content with empty name"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "empty name -> note"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "name": ["  My   Title  "],
                    "content": ["My Title with extra spaces"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "whitespace normalization in name/content comparison"
    );
}

#[test]
fn url_validation_in_type_detection_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "like-of": ["not a valid url"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "invalid like-of URL should fall through to note"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "video": ["https://example.com/video.mp4"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Video),
        "valid video URL should detect video type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "photo": ["https://example.com/photo.jpg"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Photo),
        "valid photo URL should detect photo type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "photo": ["invalid-url"],
                    "content": ["Fallback content"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "invalid photo URL should fall through to content check"
    );
}

#[test]
fn spec_algorithm_order_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "in-reply-to": ["https://example.com/post"],
                    "like-of": ["https://example.com/liked"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Reply),
        "RSVP checked before reply, reply before like"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "like-of": ["https://example.com/liked"],
                    "photo": ["https://example.com/photo.jpg"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Like),
        "like-of checked before photo"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "video": ["https://example.com/video.mp4"],
                    "photo": ["https://example.com/photo.jpg"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Video),
        "video checked before photo"
    );
}

#[test]
fn no_content_no_summary_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {}
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "empty properties should be a note"
    );
}

#[test]
fn experimental_properties_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "bookmark-of": ["https://example.com/bookmarked"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Bookmark),
        "bookmark-of should detect Bookmark type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "quotation-of": ["https://example.com/quoted"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Quotation),
        "quotation-of should detect Quotation type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "follow-of": ["https://example.com/followed"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Follow),
        "follow-of should detect Follow type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "listen-of": ["https://example.com/listened"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Listen),
        "listen-of should detect Listen type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "watch-of": ["https://example.com/watched"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Watch),
        "watch-of should detect Watch type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "read-of": ["https://example.com/read"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Read),
        "read-of should detect Read type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "jam-of": ["https://example.com/jammed"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Jam),
        "jam-of should detect Jam type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "gameplay-of": ["https://example.com/played"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::GamePlay),
        "gameplay-of should detect GamePlay type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "checkin": ["https://example.com/place"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::CheckIn),
        "checkin should detect CheckIn type"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "mention-of": ["https://example.com/mentioned"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Mention),
        "mention-of should detect Mention type"
    );
}

#[test]
fn experimental_properties_url_validation_test() {
    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "bookmark-of": ["not a valid url"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "invalid bookmark-of URL should fall through to note"
    );

    assert_eq!(
        resolve_from_object(
            serde_json::json!({
                "type": ["h-entry"],
                "properties": {
                    "listen-of": ["invalid"],
                    "content": ["Some content"]
                }
            })
            .try_into()
            .unwrap()
        ),
        Some(Type::Note),
        "invalid listen-of URL should fall through to content check"
    );
}
