//! OpenSim-compatible XML2 linkset assets.

#![allow(clippy::missing_errors_doc)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::format_push_string)]

use std::fmt::Write as _;
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use base64::Engine as _;
use libremetaverse_types::compat::XmlTextReader;
use libremetaverse_types::{AssetType, Color4, InventoryType, Quaternion, UUID, Vector3};
use roxmltree::Node;

use crate::prim_object::{PrimObject, PrimObjectInventoryBlockItemBlock, asset_type};
use crate::{Error, Primitive, PrimitiveTextureEntry};

const MAX_XML_BYTES: usize = 64 * 1024 * 1024;

#[derive(Clone, Debug, PartialEq)]
pub struct AssetPrim {
    pub children: Vec<PrimObject>,
    pub parent: Option<PrimObject>,
    asset_data: Vec<u8>,
    asset_id: UUID,
}

fn escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

fn element(output: &mut String, name: &str, value: impl std::fmt::Display) {
    let _ = write!(output, "<{name}>{}</{name}>", escape(&value.to_string()));
}

fn uuid_element(output: &mut String, name: &str, value: UUID) {
    let _ = write!(output, "<{name}><UUID>{value}</UUID></{name}>");
}

fn vector_element(output: &mut String, name: &str, value: Vector3) {
    let _ = write!(
        output,
        "<{name}><X>{}</X><Y>{}</Y><Z>{}</Z></{name}>",
        value.x, value.y, value.z
    );
}

fn quaternion_element(output: &mut String, name: &str, value: Quaternion) {
    let _ = write!(
        output,
        "<{name}><X>{}</X><Y>{}</Y><Z>{}</Z><W>{}</W></{name}>",
        value.x, value.y, value.z, value.w
    );
}

fn color_element(output: &mut String, name: &str, value: Color4) {
    let _ = write!(
        output,
        "<{name}><R>{}</R><G>{}</G><B>{}</B><A>{}</A></{name}>",
        value.r, value.g, value.b, value.a
    );
}

fn unix_seconds(value: SystemTime) -> u64 {
    value
        .duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::ZERO)
        .as_secs()
}

fn child<'a, 'input>(node: Node<'a, 'input>, name: &str) -> Option<Node<'a, 'input>> {
    node.children()
        .find(|candidate| candidate.is_element() && candidate.tag_name().name() == name)
}

fn text(node: Node<'_, '_>, name: &str) -> Option<String> {
    child(node, name)
        .and_then(|value| value.text())
        .map(str::trim)
        .map(str::to_owned)
}

fn parsed<T>(node: Node<'_, '_>, name: &str, default: T) -> Result<T, Error>
where
    T: FromStr,
{
    text(node, name).map_or(Ok(default), |value| {
        value.parse().map_err(|_| Error::Argument)
    })
}

fn boolean(node: Node<'_, '_>, name: &str, default: bool) -> Result<bool, Error> {
    text(node, name).map_or(Ok(default), |value| {
        match value.to_ascii_lowercase().as_str() {
            "true" | "1" => Ok(true),
            "false" | "0" => Ok(false),
            _ => Err(Error::Argument),
        }
    })
}

fn uuid(node: Node<'_, '_>, name: &str) -> Result<UUID, Error> {
    let Some(container) = child(node, name) else {
        return Ok(UUID::zero());
    };
    let value = container
        .descendants()
        .find(|candidate| {
            candidate.is_element() && matches!(candidate.tag_name().name(), "UUID" | "Guid")
        })
        .and_then(|candidate| candidate.text())
        .or_else(|| container.text())
        .map(str::trim)
        .unwrap_or_default();
    if value.is_empty() {
        Ok(UUID::zero())
    } else {
        UUID::new_with_string(value.to_owned())
    }
}

fn vector(node: Node<'_, '_>, name: &str) -> Result<Vector3, Error> {
    let Some(value) = child(node, name) else {
        return Ok(Vector3::zero());
    };
    Ok(Vector3 {
        x: parsed(value, "X", 0.0_f32)?,
        y: parsed(value, "Y", 0.0_f32)?,
        z: parsed(value, "Z", 0.0_f32)?,
    })
}

fn quaternion(node: Node<'_, '_>, name: &str) -> Result<Quaternion, Error> {
    let Some(value) = child(node, name) else {
        return Ok(Quaternion::identity());
    };
    Quaternion::new_with_single_single_single_single(
        parsed(value, "X", 0.0_f32)?,
        parsed(value, "Y", 0.0_f32)?,
        parsed(value, "Z", 0.0_f32)?,
        parsed(value, "W", 1.0_f32)?,
    )
}

fn color(node: Node<'_, '_>, name: &str) -> Result<Color4, Error> {
    let Some(value) = child(node, name) else {
        return Ok(Color4::default());
    };
    Ok(Color4 {
        r: parsed(value, "R", 0.0_f32)?.clamp(0.0, 1.0),
        g: parsed(value, "G", 0.0_f32)?.clamp(0.0, 1.0),
        b: parsed(value, "B", 0.0_f32)?.clamp(0.0, 1.0),
        a: parsed(value, "A", 0.0_f32)?.clamp(0.0, 1.0),
    })
}

fn write_inventory(output: &mut String, prim: &PrimObject) {
    output.push_str("<TaskInventory>");
    for item in &prim.inventory.items {
        output.push_str("<TaskInventoryItem>");
        uuid_element(output, "AssetID", item.asset_id);
        element(output, "BasePermissions", item.perms_base);
        element(output, "CreationDate", unix_seconds(item.creation_date));
        uuid_element(output, "CreatorID", item.creator_id);
        element(output, "Description", &item.description);
        element(output, "EveryonePermissions", item.perms_everyone);
        element(output, "Flags", item.flags);
        uuid_element(output, "GroupID", item.group_id);
        element(output, "GroupPermissions", item.perms_group);
        element(output, "InvType", item.inv_type.raw());
        uuid_element(output, "ItemID", item.id);
        uuid_element(output, "OldItemID", UUID::zero());
        uuid_element(output, "LastOwnerID", item.last_owner_id);
        element(output, "Name", &item.name);
        element(output, "NextPermissions", item.perms_next_owner);
        uuid_element(output, "OwnerID", item.owner_id);
        element(output, "CurrentPermissions", item.perms_owner);
        uuid_element(output, "ParentID", prim.id);
        uuid_element(output, "ParentPartID", prim.id);
        uuid_element(output, "PermsGranter", item.perms_granter_id);
        element(output, "PermsMask", item.perms_base);
        element(output, "Type", item.type_ as i8);
        element(output, "OwnerChanged", false);
        output.push_str("</TaskInventoryItem>");
    }
    output.push_str("</TaskInventory>");
}

fn read_inventory(node: Node<'_, '_>) -> Result<Vec<PrimObjectInventoryBlockItemBlock>, Error> {
    let Some(inventory) = child(node, "TaskInventory") else {
        return Ok(Vec::new());
    };
    inventory
        .children()
        .filter(|entry| entry.is_element() && entry.tag_name().name() == "TaskInventoryItem")
        .map(|entry| {
            let inv_type = parsed(entry, "InvType", -1_i32)?;
            Ok(PrimObjectInventoryBlockItemBlock {
                asset_id: uuid(entry, "AssetID")?,
                creation_date: UNIX_EPOCH
                    .checked_add(Duration::from_secs(parsed(entry, "CreationDate", 0_u64)?))
                    .ok_or(Error::Argument)?,
                creator_id: uuid(entry, "CreatorID")?,
                description: text(entry, "Description").unwrap_or_default(),
                flags: parsed(entry, "Flags", 0_i32)?,
                group_id: uuid(entry, "GroupID")?,
                id: uuid(entry, "ItemID")?,
                inv_type: InventoryType::from_raw(
                    i8::try_from(inv_type).map_err(|_| Error::Argument)?,
                ),
                last_owner_id: uuid(entry, "LastOwnerID")?,
                name: text(entry, "Name").unwrap_or_default(),
                owner_id: uuid(entry, "OwnerID")?,
                perms_base: parsed(entry, "BasePermissions", 0_u32)?,
                perms_everyone: parsed(entry, "EveryonePermissions", 0_u32)?,
                perms_granter_id: uuid(entry, "PermsGranter")?,
                perms_group: parsed(entry, "GroupPermissions", 0_u32)?,
                perms_next_owner: parsed(entry, "NextPermissions", 0_u32)?,
                perms_owner: parsed(entry, "CurrentPermissions", 0_u32)?,
                type_: asset_type(parsed(entry, "Type", -1_i32)?)?,
            })
        })
        .collect()
}

fn write_prim(
    output: &mut String,
    prim: &PrimObject,
    parent: Option<&PrimObject>,
) -> Result<(), Error> {
    output.push_str("<SceneObjectPart xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">");
    element(output, "AllowedDrop", prim.allowed_drop);
    uuid_element(output, "CreatorID", prim.creator_id);
    uuid_element(output, "FolderID", prim.folder_id);
    element(output, "InventorySerial", prim.inventory.serial);
    write_inventory(output, prim);
    let mut flags = 0_u32;
    for (enabled, flag) in [
        (prim.use_physics, libremetaverse_types::PrimFlags::PHYSICS),
        (prim.phantom, libremetaverse_types::PrimFlags::PHANTOM),
        (
            prim.die_at_edge,
            libremetaverse_types::PrimFlags::DIE_AT_EDGE,
        ),
        (
            prim.return_at_edge,
            libremetaverse_types::PrimFlags::RETURN_AT_EDGE,
        ),
        (prim.temporary, libremetaverse_types::PrimFlags::TEMPORARY),
        (prim.sandbox, libremetaverse_types::PrimFlags::SANDBOX),
        (
            prim.allowed_drop,
            libremetaverse_types::PrimFlags::ALLOW_INVENTORY_DROP,
        ),
    ] {
        if enabled {
            flags |= flag.0;
        }
    }
    element(output, "ObjectFlags", flags);
    uuid_element(output, "UUID", prim.id);
    element(output, "LocalId", prim.local_id);
    element(output, "Name", &prim.name);
    element(output, "Material", prim.material);
    element(output, "PassTouches", prim.pass_touches);
    element(output, "RegionHandle", prim.region_handle);
    element(output, "ScriptAccessPin", prim.remote_script_access_pin);
    vector_element(
        output,
        "GroupPosition",
        parent.map_or(prim.position, |root| root.position),
    );
    vector_element(
        output,
        "OffsetPosition",
        if parent.is_some() {
            prim.position
        } else {
            Vector3::zero()
        },
    );
    quaternion_element(output, "RotationOffset", prim.rotation);
    vector_element(output, "Velocity", prim.velocity);
    vector_element(output, "RotationalVelocity", Vector3::zero());
    vector_element(output, "AngularVelocity", prim.angular_velocity);
    vector_element(output, "Acceleration", prim.acceleration);
    element(output, "Description", &prim.description);
    color_element(output, "Color", prim.text_color);
    element(output, "Text", &prim.text);
    element(output, "SitName", &prim.sit_name);
    element(output, "TouchName", &prim.touch_name);
    element(output, "LinkNum", prim.link_number);
    element(output, "ClickAction", prim.click_action);
    output.push_str("<Shape>");
    element(
        output,
        "PathBegin",
        Primitive::pack_begin_cut(prim.shape.path_begin)?,
    );
    element(output, "PathCurve", prim.shape.path_curve);
    element(
        output,
        "PathEnd",
        Primitive::pack_end_cut(prim.shape.path_end)?,
    );
    element(
        output,
        "PathRadiusOffset",
        Primitive::pack_path_twist(prim.shape.path_radius_offset)?,
    );
    element(
        output,
        "PathRevolutions",
        Primitive::pack_path_revolutions(prim.shape.path_revolutions)?,
    );
    element(
        output,
        "PathScaleX",
        Primitive::pack_path_scale(prim.shape.path_scale_x)?,
    );
    element(
        output,
        "PathScaleY",
        Primitive::pack_path_scale(prim.shape.path_scale_y)?,
    );
    element(
        output,
        "PathShearX",
        Primitive::pack_path_shear(prim.shape.path_shear_x)?,
    );
    element(
        output,
        "PathShearY",
        Primitive::pack_path_shear(prim.shape.path_shear_y)?,
    );
    element(
        output,
        "PathSkew",
        Primitive::pack_path_twist(prim.shape.path_skew)?,
    );
    element(
        output,
        "PathTaperX",
        Primitive::pack_path_taper(prim.shape.path_taper_x)?,
    );
    element(
        output,
        "PathTaperY",
        Primitive::pack_path_taper(prim.shape.path_taper_y)?,
    );
    element(
        output,
        "PathTwist",
        Primitive::pack_path_twist(prim.shape.path_twist)?,
    );
    element(
        output,
        "PathTwistBegin",
        Primitive::pack_path_twist(prim.shape.path_twist_begin)?,
    );
    element(output, "PCode", prim.p_code);
    element(
        output,
        "ProfileBegin",
        Primitive::pack_begin_cut(prim.shape.profile_begin)?,
    );
    element(
        output,
        "ProfileEnd",
        Primitive::pack_end_cut(prim.shape.profile_end)?,
    );
    element(
        output,
        "ProfileHollow",
        Primitive::pack_profile_hollow(prim.shape.profile_hollow)?,
    );
    vector_element(output, "Scale", prim.scale);
    element(output, "State", prim.state);
    element(output, "ProfileShape", prim.shape.profile_curve & 0x0f);
    element(output, "HollowShape", prim.shape.profile_curve & 0xf0);
    element(output, "ProfileCurve", prim.shape.profile_curve);
    let texture_bytes = prim
        .textures
        .as_ref()
        .map(PrimitiveTextureEntry::get_bytes)
        .transpose()?
        .unwrap_or_default();
    element(
        output,
        "TextureEntry",
        base64::engine::general_purpose::STANDARD.encode(texture_bytes),
    );
    output.push_str("<ExtraParams></ExtraParams>");
    uuid_element(output, "SculptTexture", prim.sculpt.texture);
    element(output, "SculptType", prim.sculpt.type_);
    output.push_str("<SculptData></SculptData>");
    element(output, "FlexiSoftness", prim.flexible.softness);
    element(output, "FlexiTension", prim.flexible.tension);
    element(output, "FlexiDrag", prim.flexible.drag);
    element(output, "FlexiGravity", prim.flexible.gravity);
    element(output, "FlexiWind", prim.flexible.wind);
    element(output, "FlexiForceX", prim.flexible.force.x);
    element(output, "FlexiForceY", prim.flexible.force.y);
    element(output, "FlexiForceZ", prim.flexible.force.z);
    element(output, "LightColorR", prim.light.color.r);
    element(output, "LightColorG", prim.light.color.g);
    element(output, "LightColorB", prim.light.color.b);
    element(output, "LightColorA", prim.light.color.a);
    element(output, "LightRadius", prim.light.radius);
    element(output, "LightCutoff", prim.light.cutoff);
    element(output, "LightFalloff", prim.light.falloff);
    element(output, "LightIntensity", prim.light.intensity);
    element(
        output,
        "FlexiEntry",
        prim.flexible != crate::prim_object::PrimObjectFlexibleBlock::new()?,
    );
    element(
        output,
        "LightEntry",
        prim.light != crate::prim_object::PrimObjectLightBlock::new()?,
    );
    element(output, "SculptEntry", prim.sculpt.texture != UUID::zero());
    output.push_str("</Shape>");
    vector_element(output, "Scale", prim.scale);
    element(output, "UpdateFlag", 0);
    quaternion_element(output, "SitTargetOrientation", prim.sit_rotation);
    vector_element(output, "SitTargetPosition", prim.sit_offset);
    vector_element(output, "SitTargetPositionLL", prim.sit_offset);
    quaternion_element(output, "SitTargetOrientationLL", prim.sit_rotation);
    element(output, "ParentID", prim.parent_id);
    element(output, "CreationDate", unix_seconds(prim.creation_date));
    element(output, "RezDate", unix_seconds(prim.rez_date));
    element(output, "Category", 0);
    element(output, "SalePrice", prim.sale_price);
    element(output, "ObjectSaleType", prim.sale_type);
    element(output, "OwnershipCost", 0);
    uuid_element(output, "GroupID", prim.group_id);
    uuid_element(output, "OwnerID", prim.owner_id);
    uuid_element(output, "LastOwnerID", prim.last_owner_id);
    element(output, "BaseMask", prim.perms_base);
    element(output, "OwnerMask", prim.perms_owner);
    element(output, "GroupMask", prim.perms_group);
    element(output, "EveryoneMask", prim.perms_everyone);
    element(output, "NextOwnerMask", prim.perms_next_owner);
    element(output, "Flags", "None");
    uuid_element(output, "SitTargetAvatar", UUID::zero());
    uuid_element(output, "CollisionSound", prim.collision_sound);
    element(output, "CollisionSoundVolume", prim.collision_sound_volume);
    element(
        output,
        "ScriptState",
        base64::engine::general_purpose::STANDARD.encode(&prim.script_state),
    );
    output.push_str("</SceneObjectPart>");
    Ok(())
}

fn read_prim(node: Node<'_, '_>) -> Result<PrimObject, Error> {
    let mut prim = PrimObject::new()?;
    prim.allowed_drop = boolean(node, "AllowedDrop", true)?;
    prim.creator_id = uuid(node, "CreatorID")?;
    prim.folder_id = uuid(node, "FolderID")?;
    prim.inventory.serial = parsed(node, "InventorySerial", 0_i32)?;
    prim.inventory.items = read_inventory(node)?;
    let flags = parsed(node, "ObjectFlags", 0_u32)?;
    prim.use_physics = flags & libremetaverse_types::PrimFlags::PHYSICS.0 != 0;
    prim.phantom = flags & libremetaverse_types::PrimFlags::PHANTOM.0 != 0;
    prim.die_at_edge = flags & libremetaverse_types::PrimFlags::DIE_AT_EDGE.0 != 0;
    prim.return_at_edge = flags & libremetaverse_types::PrimFlags::RETURN_AT_EDGE.0 != 0;
    prim.temporary = flags & libremetaverse_types::PrimFlags::TEMPORARY.0 != 0;
    prim.sandbox = flags & libremetaverse_types::PrimFlags::SANDBOX.0 != 0;
    prim.allowed_drop |= flags & libremetaverse_types::PrimFlags::ALLOW_INVENTORY_DROP.0 != 0;
    prim.id = uuid(node, "UUID")?;
    prim.local_id = parsed(node, "LocalId", 0_u32)?;
    prim.name = text(node, "Name").unwrap_or_default();
    prim.material = parsed(node, "Material", 0_i32)?;
    prim.pass_touches = boolean(node, "PassTouches", false)?;
    prim.region_handle = parsed(node, "RegionHandle", 0_u64)?;
    prim.remote_script_access_pin = parsed(node, "ScriptAccessPin", 0_i32)?;
    let group_position = vector(node, "GroupPosition")?;
    let offset_position = vector(node, "OffsetPosition")?;
    prim.rotation = quaternion(node, "RotationOffset")?;
    prim.velocity = vector(node, "Velocity")?;
    prim.angular_velocity = vector(node, "AngularVelocity")?;
    prim.acceleration = vector(node, "Acceleration")?;
    prim.description = text(node, "Description").unwrap_or_default();
    prim.text_color = color(node, "Color")?;
    prim.text = text(node, "Text").unwrap_or_default();
    prim.sit_name = text(node, "SitName").unwrap_or_default();
    prim.touch_name = text(node, "TouchName").unwrap_or_default();
    prim.link_number = parsed(node, "LinkNum", 0_i32)?;
    prim.click_action = parsed(node, "ClickAction", 0_i32)?;
    if let Some(shape) = child(node, "Shape") {
        prim.shape.path_begin = Primitive::unpack_begin_cut(parsed(shape, "PathBegin", 0_u16)?)?;
        prim.shape.path_curve = parsed(shape, "PathCurve", 0_i32)?;
        prim.shape.path_end = Primitive::unpack_end_cut(parsed(shape, "PathEnd", 0_u16)?)?;
        prim.shape.path_radius_offset =
            Primitive::unpack_path_twist(parsed(shape, "PathRadiusOffset", 0_i8)?)?;
        prim.shape.path_revolutions =
            Primitive::unpack_path_revolutions(parsed(shape, "PathRevolutions", 0_u8)?)?;
        prim.shape.path_scale_x = Primitive::unpack_path_scale(parsed(shape, "PathScaleX", 0_u8)?)?;
        prim.shape.path_scale_y = Primitive::unpack_path_scale(parsed(shape, "PathScaleY", 0_u8)?)?;
        prim.shape.path_shear_x = Primitive::unpack_path_shear(parsed(shape, "PathShearX", 0_i8)?)?;
        prim.shape.path_shear_y = Primitive::unpack_path_shear(parsed(shape, "PathShearY", 0_i8)?)?;
        prim.shape.path_skew = Primitive::unpack_path_twist(parsed(shape, "PathSkew", 0_i8)?)?;
        prim.shape.path_taper_x = Primitive::unpack_path_taper(parsed(shape, "PathTaperX", 0_i8)?)?;
        prim.shape.path_taper_y = Primitive::unpack_path_taper(parsed(shape, "PathTaperY", 0_i8)?)?;
        prim.shape.path_twist = Primitive::unpack_path_twist(parsed(shape, "PathTwist", 0_i8)?)?;
        prim.shape.path_twist_begin =
            Primitive::unpack_path_twist(parsed(shape, "PathTwistBegin", 0_i8)?)?;
        prim.p_code = parsed(shape, "PCode", 0_i32)?;
        prim.shape.profile_begin =
            Primitive::unpack_begin_cut(parsed(shape, "ProfileBegin", 0_u16)?)?;
        prim.shape.profile_end = Primitive::unpack_end_cut(parsed(shape, "ProfileEnd", 0_u16)?)?;
        prim.shape.profile_hollow =
            Primitive::unpack_profile_hollow(parsed(shape, "ProfileHollow", 0_u16)?)?;
        prim.scale = vector(shape, "Scale")?;
        prim.state = parsed(shape, "State", 0_i32)?;
        prim.shape.profile_curve = parsed(shape, "ProfileCurve", 0_i32)?;
        if let Some(encoded) = text(shape, "TextureEntry") {
            let bytes = base64::engine::general_purpose::STANDARD
                .decode(encoded)
                .map_err(|_| Error::Argument)?;
            if !bytes.is_empty() {
                prim.textures = Some(PrimitiveTextureEntry::new_with_bytes_int32_int32(
                    bytes.clone(),
                    0,
                    i32::try_from(bytes.len()).map_err(|_| Error::Argument)?,
                )?);
            }
        }
        prim.sculpt.texture = uuid(shape, "SculptTexture")?;
        prim.sculpt.type_ = parsed(shape, "SculptType", 0_i32)?;
        prim.flexible.softness = parsed(shape, "FlexiSoftness", 0_i32)?;
        prim.flexible.tension = parsed(shape, "FlexiTension", 0.0_f32)?;
        prim.flexible.drag = parsed(shape, "FlexiDrag", 0.0_f32)?;
        prim.flexible.gravity = parsed(shape, "FlexiGravity", 0.0_f32)?;
        prim.flexible.wind = parsed(shape, "FlexiWind", 0.0_f32)?;
        prim.flexible.force = Vector3 {
            x: parsed(shape, "FlexiForceX", 0.0_f32)?,
            y: parsed(shape, "FlexiForceY", 0.0_f32)?,
            z: parsed(shape, "FlexiForceZ", 0.0_f32)?,
        };
        prim.light.color = Color4 {
            r: parsed(shape, "LightColorR", 0.0_f32)?,
            g: parsed(shape, "LightColorG", 0.0_f32)?,
            b: parsed(shape, "LightColorB", 0.0_f32)?,
            a: parsed(shape, "LightColorA", 0.0_f32)?,
        };
        prim.light.radius = parsed(shape, "LightRadius", 0.0_f32)?;
        prim.light.cutoff = parsed(shape, "LightCutoff", 0.0_f32)?;
        prim.light.falloff = parsed(shape, "LightFalloff", 0.0_f32)?;
        prim.light.intensity = parsed(shape, "LightIntensity", 0.0_f32)?;
    }
    prim.scale = child(node, "Scale").map_or(Ok(prim.scale), |_| vector(node, "Scale"))?;
    prim.sit_offset = vector(node, "SitTargetPositionLL")?;
    prim.sit_rotation = quaternion(node, "SitTargetOrientationLL")?;
    prim.parent_id = parsed(node, "ParentID", 0_u32)?;
    prim.position = if prim.parent_id == 0 {
        group_position
    } else {
        offset_position
    };
    prim.creation_date = UNIX_EPOCH
        .checked_add(Duration::from_secs(parsed(node, "CreationDate", 0_u64)?))
        .ok_or(Error::Argument)?;
    prim.rez_date = UNIX_EPOCH
        .checked_add(Duration::from_secs(parsed(node, "RezDate", 0_u64)?))
        .ok_or(Error::Argument)?;
    prim.sale_price = parsed(node, "SalePrice", 0_i32)?;
    prim.sale_type = parsed(node, "ObjectSaleType", 0_i32)?;
    prim.group_id = uuid(node, "GroupID")?;
    prim.owner_id = uuid(node, "OwnerID")?;
    prim.last_owner_id = uuid(node, "LastOwnerID")?;
    prim.perms_base = parsed(node, "BaseMask", 0_u32)?;
    prim.perms_owner = parsed(node, "OwnerMask", 0_u32)?;
    prim.perms_group = parsed(node, "GroupMask", 0_u32)?;
    prim.perms_everyone = parsed(node, "EveryoneMask", 0_u32)?;
    prim.perms_next_owner = parsed(node, "NextOwnerMask", 0_u32)?;
    prim.collision_sound = uuid(node, "CollisionSound")?;
    prim.collision_sound_volume = parsed(node, "CollisionSoundVolume", 0.0_f32)?;
    if let Some(encoded) = text(node, "ScriptState") {
        prim.script_state = base64::engine::general_purpose::STANDARD
            .decode(encoded)
            .map_err(|_| Error::Argument)?;
    }
    Ok(prim)
}

impl AssetPrim {
    pub fn new_with_constructor() -> Result<Self, Error> {
        Ok(Self {
            children: Vec::new(),
            parent: None,
            asset_data: Vec::new(),
            asset_id: UUID::zero(),
        })
    }

    pub fn new_with_prim_object_list(
        parent: PrimObject,
        children: Vec<PrimObject>,
    ) -> Result<Self, Error> {
        Ok(Self {
            asset_id: parent.id,
            children,
            parent: Some(parent),
            asset_data: Vec::new(),
        })
    }

    pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
        if asset_data.len() > MAX_XML_BYTES {
            return Err(Error::Argument);
        }
        Ok(Self {
            children: Vec::new(),
            parent: None,
            asset_data,
            asset_id,
        })
    }

    pub fn new_with_string(xml_data: String) -> Result<Self, Error> {
        let mut value = Self::new_with_uuid_bytes(UUID::zero(), xml_data.as_bytes().to_vec())?;
        if !value.decode_xml(xml_data)? {
            return Err(Error::Argument);
        }
        Ok(value)
    }

    pub const fn asset_type(&self) -> AssetType {
        AssetType::Object
    }
    pub const fn asset_id(&self) -> UUID {
        self.asset_id
    }
    pub fn native_bytes(&self) -> &[u8] {
        &self.asset_data
    }

    pub fn encode(&mut self) -> Result<(), Error> {
        self.asset_data = self.encode_xml()?.into_bytes();
        Ok(())
    }

    pub fn decode(&mut self) -> Result<bool, Error> {
        let Ok(xml) = String::from_utf8(self.asset_data.clone()) else {
            return Ok(false);
        };
        match self.decode_xml(xml) {
            Ok(value) => Ok(value),
            Err(_) => Ok(false),
        }
    }

    pub fn encode_xml(&self) -> Result<String, Error> {
        let mut output =
            String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><SceneObjectGroup>");
        if let Some(parent) = &self.parent {
            write_prim(&mut output, parent, None)?;
        }
        output.push_str("<OtherParts>");
        for child in &self.children {
            write_prim(&mut output, child, self.parent.as_ref())?;
        }
        output.push_str("</OtherParts></SceneObjectGroup>");
        Ok(output)
    }

    pub fn decode_xml(&mut self, xml_data: String) -> Result<bool, Error> {
        if xml_data.len() > MAX_XML_BYTES {
            return Err(Error::Argument);
        }
        let document = roxmltree::Document::parse(&xml_data).map_err(|_| Error::Argument)?;
        let root = document.root_element();
        if root.tag_name().name() != "SceneObjectGroup" {
            return Ok(false);
        }
        let Some(parent_node) = root
            .children()
            .find(|node| node.is_element() && node.tag_name().name() == "SceneObjectPart")
        else {
            return Ok(false);
        };
        let parent = read_prim(parent_node)?;
        let children = child(root, "OtherParts")
            .into_iter()
            .flat_map(|container| container.children())
            .filter(|node| node.is_element() && node.tag_name().name() == "SceneObjectPart")
            .map(read_prim)
            .collect::<Result<Vec<_>, _>>()?;
        if self.asset_id == UUID::zero() {
            self.asset_id = parent.id;
        }
        self.parent = Some(parent);
        self.children = children;
        self.asset_data = xml_data.into_bytes();
        Ok(true)
    }

    pub fn load_prim(reader: XmlTextReader) -> Result<PrimObject, Error> {
        if reader.0.len() > MAX_XML_BYTES {
            return Err(Error::Argument);
        }
        let document = roxmltree::Document::parse(&reader.0).map_err(|_| Error::Argument)?;
        let node = document
            .descendants()
            .find(|node| node.is_element() && node.tag_name().name() == "SceneObjectPart")
            .ok_or(Error::Argument)?;
        read_prim(node)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn xml2_round_trip_preserves_linkset_and_inventory() {
        let mut root = PrimObject::new().unwrap();
        root.id = UUID::new_with_string("7bd3f7e3-2afc-44aa-a495-b741e4189f61".into()).unwrap();
        root.name = "OpenSim root & <fixture>".into();
        root.position = Vector3 {
            x: 128.0,
            y: 64.0,
            z: 25.0,
        };
        root.p_code = 9;
        root.shape.path_end = 1.0;
        root.shape.path_scale_x = 1.0;
        root.shape.path_scale_y = 1.0;
        root.shape.path_revolutions = 1.0;
        root.shape.profile_end = 1.0;
        root.script_state = vec![1, 2, 3];
        let mut item = PrimObjectInventoryBlockItemBlock::new().unwrap();
        item.name = "script".into();
        item.type_ = AssetType::LSLText;
        root.inventory.items.push(item);
        let mut child = root.clone();
        child.id = UUID::new_with_string("ff6863c7-7acb-4e81-a28b-f0f162f06455".into()).unwrap();
        child.name = "child".into();
        child.parent_id = root.local_id.max(1);
        child.position = Vector3 {
            x: 1.0,
            y: 2.0,
            z: 3.0,
        };
        let asset =
            AssetPrim::new_with_prim_object_list(root.clone(), vec![child.clone()]).unwrap();
        let xml = asset.encode_xml().unwrap();
        let decoded = AssetPrim::new_with_string(xml).unwrap();
        assert_eq!(decoded.parent.as_ref().unwrap().name, root.name);
        assert_eq!(decoded.children.len(), 1);
        assert_eq!(decoded.children[0].position, child.position);
        assert_eq!(decoded.children[0].script_state, child.script_state);
        assert_eq!(decoded.asset_type(), AssetType::Object);
    }

    #[test]
    fn malformed_or_non_group_xml_is_rejected_without_partial_state() {
        let mut asset = AssetPrim::new_with_constructor().unwrap();
        assert!(!asset.decode_xml("<not-a-group/>".into()).unwrap());
        assert!(asset.decode_xml("<SceneObjectGroup>".into()).is_err());
        assert!(asset.parent.is_none());
    }
}
