use crate::PolymodelClient;
use crate::Route;
use crate::session::SessionIdentity;
use crate::thing_card::ThingCard;
use crate::thing_detail::thing_detail_href;
use dioxus::prelude::*;
use jacquard_common::deps::smol_str::SmolStr;
use jacquard_common::types::string::{Did, Handle, UriValue};
use jacquard_common::xrpc::XrpcClient;
use polymodel_api::space_polymodel::actor::ProfileViewUnion;
use polymodel_api::space_polymodel::graph::{
    create_follow::CreateFollow, delete_follow::DeleteFollow,
};
use polymodel_api::space_polymodel::library;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProfileTab {
    Things,
    Lists,
    Makes,
    About,
}

impl ProfileTab {
    fn label(self) -> &'static str {
        match self {
            Self::Things => "Things",
            Self::Lists => "Lists",
            Self::Makes => "Makes",
            Self::About => "About",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum ProfileState {
    Loading,
    InvalidActor(String),
    Error(String),
    Populated(Box<ProfileData>),
}

#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct ProfileData {
    header: ProfileHeader,
    things: Vec<library::ThingViewBasic>,
    lists: Vec<polymodel_api::space_polymodel::actor::ProfileListView>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum ProfileFeedState {
    Loading,
    Error(String),
    Empty,
    Populated(Vec<library::ThingViewBasic>),
}

#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct ProfileHeader {
    avatar: Option<UriValue>,
    default_license: Option<SmolStr>,
    description: Option<SmolStr>,
    did: Did,
    display_name: Option<SmolStr>,
    handle: Handle,
    links: Option<Vec<UriValue>>,
    pronouns: Option<SmolStr>,
    follower_count: Option<i64>,
    following_count: Option<i64>,
    following: Option<jacquard_common::types::string::AtUri>,
    thing_count: Option<i64>,
    total_likes: Option<i64>,
    is_bsky_fallback: bool,
}

fn profile_state(pending: bool, result: Option<&Result<ProfileData, String>>) -> ProfileState {
    if pending && result.is_none() {
        return ProfileState::Loading;
    }

    match result {
        Some(Ok(data)) => ProfileState::Populated(Box::new(data.clone())),
        Some(Err(error)) if error.starts_with("Invalid actor:") => {
            ProfileState::InvalidActor(error.clone())
        }
        Some(Err(error)) => ProfileState::Error(error.clone()),
        None => ProfileState::Loading,
    }
}

fn profile_feed_state(
    pending: bool,
    result: Option<&Result<&[library::ThingViewBasic], String>>,
) -> ProfileFeedState {
    if pending && result.is_none() {
        return ProfileFeedState::Loading;
    }

    match result {
        Some(Ok(things)) => {
            if things.is_empty() {
                ProfileFeedState::Empty
            } else {
                ProfileFeedState::Populated(things.to_vec())
            }
        }
        Some(Err(error)) => ProfileFeedState::Error(error.clone()),
        None => ProfileFeedState::Loading,
    }
}

/// Build a unified display header from either union arm. The polymodel arm's
/// bare `handle` string is branded into a `Handle` here (single conversion site);
/// the bsky arm already carries a branded `Handle`. `did` is `Did` on both arms
/// and is cloned directly.
#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]
fn profile_header(value: &ProfileViewUnion) -> ProfileHeader {
    match value {
        ProfileViewUnion::ProfileView(view) => ProfileHeader {
            avatar: view.avatar.clone(),
            default_license: optional_smolstr(&view.default_license),
            description: optional_smolstr(&view.description),
            did: view.did.clone(),
            display_name: optional_smolstr(&view.display_name),
            handle: Handle::new_owned(view.handle.as_str())
                .expect("polymodel profile handle is server-validated"),
            links: view.links.clone(),
            pronouns: optional_smolstr(&view.pronouns),
            follower_count: view.follower_count,
            following_count: view.following_count,
            following: view
                .viewer
                .as_ref()
                .and_then(|viewer| viewer.following.clone()),
            thing_count: view.thing_count,
            total_likes: view.total_likes,
            is_bsky_fallback: false,
        },
        ProfileViewUnion::ProfileViewDetailed(view) => ProfileHeader {
            avatar: view.avatar.clone(),
            default_license: None,
            description: optional_smolstr(&view.description),
            did: view.did.clone(),
            display_name: optional_smolstr(&view.display_name),
            handle: view.handle.clone(),
            links: None,
            pronouns: optional_smolstr(&view.pronouns),
            follower_count: None,
            following_count: None,
            following: None,
            thing_count: None,
            total_likes: None,
            is_bsky_fallback: true,
        },
    }
}

#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]
fn optional_smolstr(value: &Option<impl AsRef<str>>) -> Option<SmolStr> {
    value
        .as_ref()
        .map(|v| v.as_ref())
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(SmolStr::from)
}

#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]
fn profile_lists(
    value: &ProfileViewUnion,
) -> Vec<polymodel_api::space_polymodel::actor::ProfileListView> {
    match value {
        ProfileViewUnion::ProfileView(view) => view.lists.clone().unwrap_or_default(),
        ProfileViewUnion::ProfileViewDetailed(_) => Vec::new(),
    }
}

/// Canonical profile route for an actor. Prefers the handle (always present
/// and validated on `library::Actor`); falls back to the DID defensively.
pub(crate) fn profile_href(actor: &library::Actor) -> Option<String> {
    let handle = actor.handle.as_ref();
    if !handle.is_empty() {
        Some(format!("/profile/{handle}"))
    } else {
        Some(format!("/profile/{}", actor.did.as_ref()))
    }
}

fn avatar_initial(header: &ProfileHeader) -> char {
    let name = header
        .display_name
        .as_ref()
        .map(SmolStr::as_str)
        .unwrap_or_else(|| header.handle.as_ref());
    name.chars().next().unwrap_or('?').to_ascii_uppercase()
}

fn header_display_label(header: &ProfileHeader) -> String {
    header
        .display_name
        .as_ref()
        .map(SmolStr::as_str)
        .filter(|s| !s.trim().is_empty())
        .map(ToString::to_string)
        .unwrap_or_else(|| header.handle.as_ref().to_string())
}

fn count_label(value: Option<i64>, noun: &str) -> Option<String> {
    value.map(|n| {
        let suffix = if n == 1 { "" } else { "s" };
        format!("{n} {noun}{suffix}")
    })
}

#[server]
async fn profile_seed(actor: String) -> Result<ProfileData, ServerFnError> {
    use jacquard_common::types::ident::AtIdentifier;

    let actor_id = AtIdentifier::new_owned(&actor).map_err(|e| {
        ServerFnError::new(format!("Invalid actor: unsupported actor identifier: {e}"))
    })?;

    let union = crate::appview::ssr::get_profile_union_from_fullstack_context(&actor_id).await?;
    let header = profile_header(&union);

    let feed =
        crate::appview::ssr::get_author_things_from_fullstack_context(&actor_id, 24, None).await?;
    let things = feed.items.into_iter().map(|item| item.thing).collect();
    let lists = profile_lists(&union);

    Ok(ProfileData {
        header,
        things,
        lists,
    })
}

fn adjust_count(current: i64, was_active: bool, is_active: bool) -> i64 {
    match (was_active, is_active) {
        (false, true) => current + 1,
        (true, false) => current.saturating_sub(1),
        _ => current,
    }
}

#[allow(clippy::useless_format)]
#[component]
pub(crate) fn Profile(actor: String) -> Element {
    rsx! { ProfileBody { actor } }
}

#[allow(clippy::useless_format)]
#[component]
pub(crate) fn ProfileSelf() -> Element {
    let session = use_context::<Signal<SessionIdentity>>();
    // Sign-in returns the user to the page they started on, not a hardcoded path.
    let return_to = use_route::<Route>().to_string();

    // Reuse the shared session resolution from the App/shell: `Unknown` is the
    // brief in-flight state (the App resolves it to a terminal Authenticated/
    // Anonymous even on failure), so /profile needs no per-page session re-check.
    match session.read().clone() {
        SessionIdentity::Unknown => rsx! {
            main { class: "profile-page product-shell",
                section { class: "profile-shell blueprint-panel",
                    h1 { class: "sr-only", "Loading profile" }
                    div { class: "state-card loading-state",
                        span { class: "status-pill", "Loading" }
                        div { class: "skeleton-line skeleton-wide" }
                        div { class: "skeleton-line" }
                        div { class: "skeleton-box" }
                    }
                }
            }
        },
        SessionIdentity::Authenticated(auth) => {
            rsx! { ProfileBody { actor: auth.handle.as_str().to_string() } }
        }
        SessionIdentity::Anonymous => rsx! {
            main { class: "profile-page product-shell",
                section { class: "profile-sign-in blueprint-panel",
                    span { class: "status-pill", "Signed out" }
                    h1 { "Your profile" }
                    p { class: "product-lede", "Sign in to view and share your Polymodel profile." }
                   form {
                       class: "session-control",
                       action: "/oauth/start",
                       method: "get",
                       input { r#type: "hidden", name: "return_to", value: "{return_to}" },
                       input {
                            r#type: "text",
                            name: "identifier",
                            placeholder: "handle.test",
                            aria_label: "ATProto handle",
                        }
                        button { class: "button button-primary", r#type: "submit", "Sign in" }
                    }
                }
            }
        },
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileBody(actor: String) -> Element {
    let session = use_context::<Signal<SessionIdentity>>();
    let active_tab = use_signal(|| ProfileTab::Things);

    let mut profile = use_server_future(move || {
        let actor = actor.clone();
        profile_seed(actor)
    })?;

    let profile_result = profile.read();
    let mapped = profile_result.as_ref().map(|r| match r {
        Ok(data) => Ok(data.clone()),
        Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()),
        Err(other) => Err(other.to_string()),
    });
    let state = profile_state(profile.pending(), mapped.as_ref());

    rsx! {
        main { class: "profile-page product-shell",
            match state {
                ProfileState::Loading => rsx! { ProfileLoading {} },
                ProfileState::InvalidActor(message) => rsx! {
                    section { class: "profile-shell blueprint-panel", aria_label: "Profile error",
                        div { class: "state-card error-state",
                            span { class: "status-pill status-error", "Error" }
                            h1 { "Profile unavailable" }
                            p { "{message}" }
                            Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" }
                        }
                    }
                },
                ProfileState::Error(message) => rsx! {
                    section { class: "profile-shell blueprint-panel", aria_label: "Profile error",
                        div { class: "state-card error-state",
                            span { class: "status-pill status-error", "Error" }
                            h1 { "Profile unavailable" }
                            p { "{message}" }
                            button { class: "button button-secondary", onclick: move |_| profile.restart(), "Retry" }
                            Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" }
                        }
                    }
                },
                ProfileState::Populated(data) => {
                    let data = *data;
                    let session_identity = session.read().clone();
                    let (is_own, is_anonymous) = match session_identity {
                        SessionIdentity::Authenticated(identity) => {
                            (identity.did == data.header.did, false)
                        }
                        SessionIdentity::Anonymous | SessionIdentity::Unknown => (false, true),
                    };
                    rsx! {
                        ProfilePopulated { data, is_own, is_anonymous, active_tab }
                    }
                }
            }
        }
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileLoading() -> Element {
    rsx! {
        section { class: "profile-shell blueprint-panel", aria_label: "Profile loading",
            h1 { class: "sr-only", "Loading profile" }
            div { class: "state-card loading-state",
                span { class: "status-pill", "Loading" }
                div { class: "skeleton-line skeleton-wide" }
                div { class: "skeleton-line" }
                div { class: "skeleton-box" }
            }
        }
    }
}

/// A bio rendered as alternating plain-text and URL segments. Newlines stay
/// inside the text segments; the bio container uses `white-space: pre-wrap` so
/// they render as authored.
enum BioSegment {
    Text(String),
    Link(String),
}

/// Split bio text into text/link segments, linkifying `http(s)://` and `www.`
/// tokens while leaving every other character (including newlines) untouched.
fn linkify_bio(text: &str) -> Vec<BioSegment> {
    let mut segments = Vec::new();
    let mut rest = text;
    while let Some(start) = next_url_start(rest) {
        let (before, from) = rest.split_at(start);
        if !before.is_empty() {
            segments.push(BioSegment::Text(before.to_string()));
        }
        let end = from.find(char::is_whitespace).unwrap_or(from.len());
        let (token, after) = from.split_at(end);
        // Keep trailing sentence punctuation out of the linked span.
        let trimmed_len = token.trim_end_matches([',', '.', ')', ';', ':', '!']).len();
        let (url, tail) = token.split_at(trimmed_len);
        segments.push(BioSegment::Link(url.to_string()));
        if !tail.is_empty() {
            segments.push(BioSegment::Text(tail.to_string()));
        }
        rest = after;
    }
    if !rest.is_empty() {
        segments.push(BioSegment::Text(rest.to_string()));
    }
    segments
}

fn next_url_start(s: &str) -> Option<usize> {
    [s.find("https://"), s.find("http://"), s.find("www.")]
        .into_iter()
        .flatten()
        .min()
}

/// Build an href for a linkified bio token, adding a scheme for `www.` tokens.
fn bio_link_href(url: &str) -> String {
    if url.starts_with("http://") || url.starts_with("https://") {
        url.to_string()
    } else {
        format!("https://{url}")
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfilePopulated(
    data: ProfileData,
    is_own: bool,
    is_anonymous: bool,
    active_tab: Signal<ProfileTab>,
) -> Element {
    let client = use_context::<PolymodelClient>();
    let header = data.header;
    let return_to = use_route::<Route>().to_string();
    let things = data.things;
    let display_label = header_display_label(&header);
    let handle_str = header.handle.as_ref().to_string();
    let avatar_url = header.avatar.as_ref().map(|u| u.as_ref().to_string());
    let initial = avatar_initial(&header);
    let description = header
        .description
        .as_ref()
        .map(SmolStr::as_str)
        .map(ToString::to_string);
    let pronouns = header
        .pronouns
        .as_ref()
        .map(SmolStr::as_str)
        .map(ToString::to_string);
    let thing_count_label = count_label(header.thing_count, "thing");
    let total_likes_label = count_label(header.total_likes, "like");
    let mut follower_count = use_signal(|| header.follower_count.unwrap_or(0));
    let follower_count_label = if header.follower_count.is_some() {
        count_label(Some(*follower_count.read()), "follower")
    } else {
        None
    };
    let following_count_label = count_label(header.following_count, "following");
    let mut following = use_signal(|| header.following.clone());
    let mut follow_pending = use_signal(|| false);
    let mut follow_error = use_signal(|| None::<String>);
    let follow_label = if following.read().is_some() {
        "Following"
    } else {
        "Follow"
    };
    let is_bsky_fallback = header.is_bsky_fallback;
    let links_rail = header.links.clone();
    let bio_segments = description.as_deref().map(linkify_bio);
    let tab = *active_tab.read();
    let follow_subject = header.did.clone();

    rsx! {
        div { class: "profile-layout",
            aside { class: "profile-rail blueprint-panel", aria_label: "Maker identity",
                if let Some(url) = &avatar_url {
                    img { class: "profile-avatar", src: "{url}", alt: "{display_label} avatar" }
                } else {
                    div { class: "profile-avatar profile-avatar-fallback", aria_label: "Avatar placeholder",
                        span { "{initial}" }
                    }
                }

                div { class: "profile-rail-identity",
                    h1 { "{display_label}" }
                    p { class: "profile-handle",
                        "@{handle_str}"
                        if let Some(pronouns) = &pronouns {
                            span { class: "profile-pronouns", " · {pronouns}" }
                        }
                    }
                    if is_bsky_fallback {
                        span { class: "profile-bsky-note", "Bluesky profile" }
                    }
                }

                if thing_count_label.is_some() || total_likes_label.is_some() || header.follower_count.is_some() || following_count_label.is_some() {
                    div { class: "profile-stats", aria_label: "Maker stats",
                        if let Some(label) = &thing_count_label {
                            span { "{label}" }
                        }
                        if let Some(label) = &total_likes_label {
                            span { "{label}" }
                        }
                        if let Some(label) = &follower_count_label {
                            span { "{label}" }
                        }
                        if let Some(label) = &following_count_label {
                            span { "{label}" }
                        }
                    }
                }

                div { class: "profile-rail-action",
                    if is_own {
                        button { class: "button button-secondary", disabled: true, "Edit profile" }
                    } else if is_anonymous {
                        form { class: "profile-follow-form", action: "/oauth/start", method: "get",
                            input { r#type: "hidden", name: "return_to", value: "{return_to}" }
                            button { class: "button button-primary", r#type: "submit", "Sign in to follow" }
                        }
                    } else {
                        button {
                            class: if following.read().is_some() { "button button-secondary" } else { "button button-primary" },
                            disabled: *follow_pending.read(),
                            aria_pressed: "{following.read().is_some()}",
                            onclick: move |_| {
                                let client = client.clone();
                                let subject = follow_subject.clone();
                                let current = following.read().clone();
                                follow_pending.set(true);
                                follow_error.set(None);
                                spawn(async move {
                                    let result = if let Some(uri) = current {
                                        match client.send(DeleteFollow { uri: Some(uri), subject: None, extra_data: None }).await {
                                            Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()),
                                            Err(error) => Err(error.to_string()),
                                        }
                                    } else {
                                        match client.send(CreateFollow { subject, extra_data: None }).await {
                                            Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()),
                                            Err(error) => Err(error.to_string()),
                                        }
                                    };
                                    match result {
                                        Ok(next) => {
                                            let was_following = following.read().is_some();
                                            following.set(next);
                                            let current_count = *follower_count.read();
                                            let is_following = following.read().is_some();
                                            follower_count.set(adjust_count(current_count, was_following, is_following));
                                        }
                                        Err(error) => follow_error.set(Some(format!("Follow failed: {error}"))),
                                    }
                                    follow_pending.set(false);
                                });
                            },
                            "{follow_label}"
                        }
                        if let Some(error) = follow_error.read().as_ref() {
                            p { class: "profile-action-error", role: "status", "{error}" }
                        }
                    }
                }

                if let Some(segments) = &bio_segments {
                    p { class: "profile-bio",
                        for segment in segments {
                            match segment {
                                BioSegment::Text(text) => rsx! { "{text}" },
                                BioSegment::Link(url) => rsx! {
                                    a { href: "{bio_link_href(url)}", rel: "nofollow noopener", "{url}" }
                                },
                            }
                        }
                    }
                }

                if let Some(links) = links_rail.as_ref().filter(|links| !links.is_empty()) {
                    ul { class: "profile-rail-links", aria_label: "Maker links",
                        for (index, link) in links.iter().enumerate() {
                            li { key: "link-{index}",
                                a { href: "{link.as_ref()}", rel: "nofollow noopener", "{link.as_ref()}" }
                            }
                        }
                    }
                }
            }

            div { class: "profile-content",
                nav { class: "tab-row profile-tabs", role: "tablist", aria_label: "Profile sections",
                    ProfileTabButton { tab: ProfileTab::Things, active_tab }
                    ProfileTabButton { tab: ProfileTab::Lists, active_tab }
                    ProfileTabButton { tab: ProfileTab::Makes, active_tab }
                    ProfileTabButton { tab: ProfileTab::About, active_tab }
                }

                div { class: "profile-tab-content",
                    match tab {
                        ProfileTab::Things => rsx! { ProfileThingsGrid { things } },
                        ProfileTab::Lists => rsx! { ProfileLists { lists: data.lists.clone() } },
                        ProfileTab::Makes => rsx! {
                            div { class: "profile-empty-panel",
                                span { class: "status-pill status-muted", "Not modelled yet" }
                                h2 { "No make activity model yet" }
                                p { "Polymodel does not yet have a dedicated make, remix, or activity record to show here without faking it." }
                            }
                        },
                        ProfileTab::About => rsx! {
                            ProfileAbout { header }
                        },
                    }
                }
            }
        }
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileTabButton(tab: ProfileTab, active_tab: Signal<ProfileTab>) -> Element {
    let is_active = *active_tab.read() == tab;
    let class = if is_active { "tab is-active" } else { "tab" };

    rsx! {
        button {
            class,
            role: "tab",
            aria_selected: "{is_active}",
            onclick: move |_| active_tab.set(tab),
            "{tab.label()}"
        }
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileThingsGrid(things: Vec<library::ThingViewBasic>) -> Element {
    let things_result: Result<&[library::ThingViewBasic], String> = Ok(things.as_slice());
    let feed = profile_feed_state(false, Some(&things_result));

    rsx! {
        section { class: "profile-things-grid browse-results-grid", aria_label: "Profile things",
            match feed {
                ProfileFeedState::Loading => rsx! {
                    for index in 0..6 {
                        div { key: "skeleton-{index}", class: "state-card loading-state",
                            span { class: "status-pill", "Loading" }
                            div { class: "skeleton-line skeleton-wide" }
                            div { class: "skeleton-line" }
                            div { class: "skeleton-box" }
                        }
                    }
                },
                ProfileFeedState::Error(error) => rsx! {
                    div { class: "state-card error-state",
                        p { "{error}" }
                    }
                },
                ProfileFeedState::Empty => rsx! {
                    div { class: "state-card empty-state",
                        span { class: "status-pill status-muted", "Empty" }
                        h2 { "No things yet" }
                        p { "This maker has not published any Polymodel things yet." }
                    }
                },
                ProfileFeedState::Populated(items) => rsx! {
                    for thing in items {
                        ProfileThingCard { key: "{thing.uri}", thing }
                    }
                },
            }
        }
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileThingCard(thing: library::ThingViewBasic) -> Element {
    let detail_href = thing_detail_href(&thing.uri);
    let creator_href = profile_href(&thing.author);
    rsx! { ThingCard { thing, detail_href, creator_href } }
}

fn list_item_count_label(count: i64) -> String {
    let suffix = if count == 1 { "" } else { "s" };
    format!("{count} item{suffix}")
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileLists(lists: Vec<polymodel_api::space_polymodel::actor::ProfileListView>) -> Element {
    rsx! {
        section { class: "profile-lists", aria_label: "Profile lists",
            if lists.is_empty() {
                div { class: "profile-empty-panel",
                    span { class: "status-pill status-muted", "Empty" }
                    h2 { "No lists yet" }
                    p { "Lists are real Polymodel records; this maker just has not published one yet." }
                }
            } else {
                for list in lists {
                    article { key: "{list.uri}", class: "profile-list-card blueprint-panel",
                        div { class: "profile-list-card-header",
                            h2 { "{list.name}" }
                            span { class: "status-pill status-muted", "{list_item_count_label(list.item_count)}" }
                        }
                        if let Some(description) = &list.description {
                            p { "{description}" }
                        }
                        dl { class: "profile-list-meta",
                            if let Some(purpose) = &list.purpose {
                                div { dt { "Purpose" } dd { "{purpose}" } }
                            }
                            div { dt { "List URI" } dd { "{list.uri.as_ref()}" } }
                        }
                    }
                }
            }
        }
    }
}

#[allow(clippy::useless_format)]
#[component]
fn ProfileAbout(header: ProfileHeader) -> Element {
    let description = header
        .description
        .as_ref()
        .map(SmolStr::as_str)
        .map(ToString::to_string);
    let pronouns = header
        .pronouns
        .as_ref()
        .map(SmolStr::as_str)
        .map(ToString::to_string);
    let links = header.links.clone();
    let default_license = header
        .default_license
        .as_ref()
        .map(SmolStr::as_str)
        .map(ToString::to_string);
    let thing_count_label = count_label(header.thing_count, "thing");
    let total_likes_label = count_label(header.total_likes, "like");
    let is_bsky_fallback = header.is_bsky_fallback;

    rsx! {
        section { class: "profile-about blueprint-panel",
            if let Some(description) = &description {
                p { class: "product-lede", "{description}" }
            } else {
                p { class: "product-lede profile-muted", "No description is published yet." }
            }
            dl { class: "profile-about-meta",
                if let Some(pronouns) = &pronouns {
                    div { dt { "Pronouns" } dd { "{pronouns}" } }
                }
                if let Some(license) = &default_license {
                    div { dt { "Default license" } dd { "{license}" } }
                }
                if let Some(label) = &thing_count_label {
                    div { dt { "Things published" } dd { "{label}" } }
                }
                if let Some(label) = &total_likes_label {
                    div { dt { "Total likes" } dd { "{label}" } }
                }
            }
            if let Some(links) = &links {
                if !links.is_empty() {
                    div { class: "profile-about-links",
                        h2 { "Links" }
                        ul {
                            for link in links {
                                li { a { href: "{link.as_ref()}", "{link.as_ref()}" } }
                            }
                        }
                    }
                }
            }
            if is_bsky_fallback {
                p { class: "profile-about-fallback-note",
                    "This actor does not have a Polymodel profile yet. Showing available Bluesky identity."
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::thing_card::thing_card_fixtures;
    use jacquard_common::types::string::{Did, Handle};
    use polymodel_api::app_bsky::actor::ProfileViewDetailed;
    use polymodel_api::space_polymodel::actor::get_profile::GetProfileOutput;
    use polymodel_api::space_polymodel::actor::{ProfileListView, ProfileView};
    use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThingsOutput;

    fn polymodel_view() -> ProfileView {
        ProfileView {
            avatar: Some(UriValue::new_owned("https://cdn.example/alice.jpg").unwrap()),
            default_license: Some("CC-BY-4.0".into()),
            indexed_at: None,
            description: Some("Maker of enclosures".into()),
            did: Did::new_owned("did:plc:alice").unwrap(),
            display_name: Some("Alice Maker".into()),
            handle: "alice.test".into(),
            pronouns: Some("she/her".into()),
            thing_count: Some(5),
            extra_data: None,
            links: Some(vec![UriValue::new_owned("https://alice.example").unwrap()]),
            printers: None,
            record: jacquard::Data::Null,
            total_likes: Some(42),
            follower_count: None,
            following_count: None,
            lists: None,
            viewer: None,
        }
    }

    fn bsky_view() -> ProfileViewDetailed {
        ProfileViewDetailed {
            associated: None,
            avatar: None,
            banner: None,
            created_at: None,
            debug: None,
            description: Some("Bluesky-only user".into()),
            did: Did::new_owned("did:plc:bob").unwrap(),
            display_name: Some("Bob".into()),
            followers_count: Some(100),
            follows_count: Some(50),
            handle: Handle::new_owned("bob.test").unwrap(),
            indexed_at: None,
            joined_via_starter_pack: None,
            labels: None,
            pinned_post: None,
            posts_count: Some(200),
            pronouns: None,
            status: None,
            verification: None,
            viewer: None,
            website: None,
            extra_data: None,
        }
    }

    fn profile_list() -> ProfileListView {
        ProfileListView {
            cid: jacquard_common::types::string::Cid::new_owned(b"bafyreilistcid").unwrap(),
            created_at: jacquard_common::types::datetime::Datetime::raw_str(
                "2024-01-01T00:00:00.000Z",
            ),
            description: Some("Shop fixtures".into()),
            item_count: 2,
            name: "Fixture jigs".into(),
            purpose: Some("curation".into()),
            uri: jacquard_common::types::string::AtUri::new_owned(
                "at://did:plc:alice/space.polymodel.graph.list/fixtures",
            )
            .unwrap(),
            extra_data: None,
        }
    }

    fn fixture_actor(did: &str, handle: &str) -> library::Actor {
        library::Actor {
            avatar: None,
            description: None,
            did: Did::new_owned(did).unwrap(),
            display_name: None,
            follower_count: None,
            following_count: None,
            handle: Handle::new_owned(handle).unwrap(),
            pronouns: None,
            thing_count: None,
            viewer: None,
            extra_data: None,
        }
    }

    #[test]
    fn profile_state_distinguishes_loading_invalid_error_and_populated() {
        assert_eq!(profile_state(true, None), ProfileState::Loading);
        assert_eq!(profile_state(false, None), ProfileState::Loading);

        let invalid = profile_state(false, Some(&Err("Invalid actor: bad".to_string())));
        assert!(matches!(invalid, ProfileState::InvalidActor(_)));

        let error = profile_state(false, Some(&Err("boom".to_string())));
        assert!(matches!(error, ProfileState::Error(_)));

        let data = ProfileData {
            header: profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))),
            things: vec![],
            lists: vec![],
        };
        let populated = profile_state(false, Some(&Ok(data)));
        assert!(matches!(populated, ProfileState::Populated(_)));
    }

    #[test]
    fn profile_feed_state_classifies_loading_empty_error_populated() {
        assert_eq!(profile_feed_state(true, None), ProfileFeedState::Loading);
        assert_eq!(profile_feed_state(false, None), ProfileFeedState::Loading);

        let empty: Result<&[library::ThingViewBasic], String> = Ok(&[]);
        assert_eq!(
            profile_feed_state(false, Some(&empty)),
            ProfileFeedState::Empty
        );

        let err: Result<&[library::ThingViewBasic], String> = Err("feed down".to_string());
        assert_eq!(
            profile_feed_state(false, Some(&err)),
            ProfileFeedState::Error("feed down".to_string())
        );

        let fixtures = thing_card_fixtures();
        let populated = profile_feed_state(false, Some(&Ok(&fixtures[..])));
        match populated {
            ProfileFeedState::Populated(items) => assert_eq!(items.len(), fixtures.len()),
            other => panic!("expected populated, got {other:?}"),
        }
    }

    #[test]
    fn profile_href_prefers_handle_then_did() {
        let actor = fixture_actor("did:plc:alice", "alice.test");
        assert_eq!(profile_href(&actor).as_deref(), Some("/profile/alice.test"));
    }

    #[test]
    fn header_extracts_polymodel_fields() {
        let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view())));
        assert_eq!(header.did.as_ref(), "did:plc:alice");
        assert_eq!(header.handle.as_ref(), "alice.test");
        assert_eq!(
            header.display_name.as_ref().map(SmolStr::as_str),
            Some("Alice Maker")
        );
        assert_eq!(header.thing_count, Some(5));
        assert_eq!(header.total_likes, Some(42));
        assert_eq!(
            header.default_license.as_ref().map(SmolStr::as_str),
            Some("CC-BY-4.0")
        );
        assert!(!header.is_bsky_fallback);
        assert!(header.avatar.is_some());
        assert!(header.links.is_some());
    }

    #[test]
    fn header_extracts_bsky_fallback_fields() {
        let header = profile_header(&ProfileViewUnion::ProfileViewDetailed(
            Box::new(bsky_view()),
        ));
        assert_eq!(header.did.as_ref(), "did:plc:bob");
        assert_eq!(header.handle.as_ref(), "bob.test");
        assert_eq!(
            header.display_name.as_ref().map(SmolStr::as_str),
            Some("Bob")
        );
        assert!(header.is_bsky_fallback);
        // Polymodel-specific fields degrade to None, not fake values.
        assert_eq!(header.thing_count, None);
        assert_eq!(header.total_likes, None);
        assert_eq!(header.default_license, None);
        assert_eq!(header.links, None);
    }

    #[test]
    fn profile_lists_extracts_polymodel_lists_only() {
        let mut view = polymodel_view();
        view.lists = Some(vec![profile_list()]);
        let lists = profile_lists(&ProfileViewUnion::ProfileView(Box::new(view)));
        assert_eq!(lists.len(), 1);
        assert_eq!(lists[0].name.as_str(), "Fixture jigs");

        let fallback = profile_lists(&ProfileViewUnion::ProfileViewDetailed(
            Box::new(bsky_view()),
        ));
        assert!(fallback.is_empty());
    }

    #[test]
    fn list_item_count_label_pluralizes() {
        assert_eq!(list_item_count_label(1), "1 item");
        assert_eq!(list_item_count_label(2), "2 items");
    }

    #[test]
    fn is_own_compares_did_without_stringify() {
        let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view())));
        let own_did = Did::new_owned("did:plc:alice").unwrap();
        let other_did = Did::new_owned("did:plc:eve").unwrap();
        assert_eq!(header.did, own_did);
        assert_ne!(header.did, other_did);
    }

    #[test]
    fn optional_smolstr_trims_and_filters_empty() {
        let trimmed: Option<&str> = Some("  hi  ");
        assert_eq!(optional_smolstr(&trimmed), Some("hi".into()));
        let blank: Option<&str> = Some("   ");
        assert_eq!(optional_smolstr(&blank), None);
        let none: Option<&str> = None;
        assert_eq!(optional_smolstr(&none), None);
    }

    #[test]
    fn get_profile_output_decodes_polymodel_arm() {
        let json = r#"{
            "$type":"space.polymodel.actor.defs#profileView",
            "did":"did:plc:alice",
            "handle":"alice.test",
            "record":null,
            "displayName":"Alice Maker",
            "description":"Maker of things",
            "pronouns":"she/her",
            "thingCount":5,
            "totalLikes":42,
            "defaultLicense":"CC-BY-4.0"
        }"#;
        let output: GetProfileOutput = serde_json::from_str(json).unwrap();
        match output.value {
            ProfileViewUnion::ProfileView(view) => {
                assert_eq!(view.did.as_ref(), "did:plc:alice");
                assert_eq!(view.handle.as_str(), "alice.test");
                assert_eq!(
                    view.display_name.as_ref().map(|s| s.as_str()),
                    Some("Alice Maker")
                );
                assert_eq!(view.thing_count, Some(5));
                assert_eq!(view.total_likes, Some(42));
            }
            _ => panic!("expected ProfileView arm"),
        }
    }

    #[test]
    fn get_profile_output_decodes_bsky_fallback_arm() {
        let json = r#"{
            "$type":"app.bsky.actor.defs#profileViewDetailed",
            "did":"did:plc:bob",
            "handle":"bob.test",
            "displayName":"Bob",
            "description":"Bluesky user",
            "followersCount":100
        }"#;
        let output: GetProfileOutput = serde_json::from_str(json).unwrap();
        match output.value {
            ProfileViewUnion::ProfileViewDetailed(view) => {
                assert_eq!(view.did.as_ref(), "did:plc:bob");
                assert_eq!(view.handle.as_str(), "bob.test");
                assert_eq!(view.display_name.as_ref().map(|s| s.as_str()), Some("Bob"));
            }
            _ => panic!("expected ProfileViewDetailed arm"),
        }
    }

    #[test]
    fn get_author_things_output_decodes_feed_items() {
        let json = r#"{
            "items":[
                {"thing":{
                    "uri":"at://did:plc:alice/space.polymodel.library.thing/thing1",
                    "cid":"bafyrei1",
                    "author":{"did":"did:plc:alice","handle":"alice.test"},
                    "name":"Thing one",
                    "modelCount":1,
                    "partCount":2,
                    "likeCount":3,
                    "saveCount":1,
                    "indexedAt":"2026-06-21T12:00:00Z",
                    "viewer":{}
                }}
            ]
        }"#;
        let output: GetAuthorThingsOutput = serde_json::from_str(json).unwrap();
        let things: Vec<_> = output.value.items.into_iter().map(|i| i.thing).collect();
        assert_eq!(things.len(), 1);
        assert_eq!(things[0].name.as_str(), "Thing one");
        assert_eq!(things[0].author.handle.as_ref(), "alice.test");
        assert_eq!(things[0].model_count, 1);
    }
}
