//! JSON export of a resolved [`Mib`].
//!
//! The entry point is [`export_payload`], which converts a resolved [`Mib`] into an
//! [`ExportPayload`]. All types derive [`Serialize`] and
//! produce a deterministic, sorted JSON representation suitable for
//! golden-file comparison tests.
//!
//! The schema uses `camelCase` field names and string representations for
//! enums (status, access, base type, etc.) to keep the JSON human-readable.

use std::cmp::Ordering;

use serde::Serialize;

use crate::mib::Mib;
use crate::mib::Oid;
use crate::mib::typedef::TypeData;
use crate::mib::types::*;
use crate::types::{Access, BaseType, Kind, Language, ResolverStrictness, Severity, Status};

/// Top-level payload for the resolved-mib export.
///
/// Built by [`export_payload`] from a resolved [`Mib`].
///
/// Collections are sorted deterministically: modules and types by name,
/// OID-bearing items (objects, notifications, groups, compliances,
/// capabilities) by numeric OID, and diagnostics by phase/code/location.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportPayload {
    /// Always `1` for this schema version.
    pub schema_version: u32,
    /// Always `"resolved-mib"`.
    pub export_kind: &'static str,
    /// The [`ResolverStrictness`] used during resolution, as a string.
    pub strictness: String,
    /// Metadata about the exporter that produced this payload.
    pub exporter: Exporter,
    /// All loaded modules, sorted by name.
    pub modules: Vec<ExportModule>,
    /// User-defined types (TEXTUAL-CONVENTIONs and type assignments), sorted by module then name.
    pub types: Vec<ExportType>,
    /// OID tree nodes that are not objects, notifications, groups, compliances, or capabilities.
    pub nodes: Vec<ExportNode>,
    /// OBJECT-TYPE definitions, sorted by OID.
    pub objects: Vec<ExportObject>,
    /// NOTIFICATION-TYPE and TRAP-TYPE definitions, sorted by OID.
    pub notifications: Vec<ExportNotification>,
    /// OBJECT-GROUP and NOTIFICATION-GROUP definitions, sorted by OID.
    pub groups: Vec<ExportGroup>,
    /// MODULE-COMPLIANCE definitions, sorted by OID.
    pub compliances: Vec<ExportCompliance>,
    /// AGENT-CAPABILITIES definitions, sorted by OID.
    pub capabilities: Vec<ExportCapability>,
    /// Diagnostics from parsing, lowering, and resolution.
    pub diagnostics: Vec<ExportDiagnostic>,
}

/// Identifies the exporter implementation, version, and commit hash.
///
/// Populated by [`export_payload`] with the crate name; version and commit
/// are left empty and can be filled in by CLI tooling.
#[derive(Serialize)]
pub struct Exporter {
    /// Exporter name, e.g. `"mib-rs"`.
    pub implementation: &'static str,
    /// Crate or tool version string, if known.
    pub version: String,
    /// Git commit hash, if known.
    pub commit: String,
}

/// A loaded MIB module with its MODULE-IDENTITY metadata.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportModule {
    /// Module name (e.g. `"IF-MIB"`).
    pub name: String,
    /// Numeric OID from MODULE-IDENTITY, if present.
    pub oid: Option<String>,
    /// SMI language version (`"SMIv1"`, `"SMIv2"`, or `"SPPI"`).
    pub language: Option<String>,
    /// ORGANIZATION clause from MODULE-IDENTITY.
    pub organization: Option<String>,
    /// CONTACT-INFO clause from MODULE-IDENTITY.
    pub contact_info: Option<String>,
    /// DESCRIPTION clause from MODULE-IDENTITY.
    pub description: Option<String>,
    /// LAST-UPDATED clause from MODULE-IDENTITY.
    pub last_updated: Option<String>,
    /// REVISION entries from MODULE-IDENTITY, in declaration order.
    pub revisions: Vec<ExportRevision>,
}

/// A REVISION entry from MODULE-IDENTITY.
#[derive(Serialize)]
pub struct ExportRevision {
    /// Revision date string (e.g. `"200206140000Z"`).
    pub date: String,
    /// DESCRIPTION text for this revision.
    pub description: Option<String>,
}

/// A resolved type definition (TEXTUAL-CONVENTION or type assignment).
///
/// The `key` field is formatted as `"Module::TypeName"` and uniquely
/// identifies the type across the entire MIB.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportType {
    /// Unique key in `"Module::TypeName"` format.
    pub key: String,
    /// Type name (e.g. `"DisplayString"`).
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// Parent type key in `"Module::TypeName"` format, if this type refines another.
    pub parent: Option<String>,
    /// Resolved base type (e.g. `"OctetString"`, `"Integer32"`).
    pub base: String,
    /// STATUS clause (only present for TEXTUAL-CONVENTIONs).
    pub status: Option<String>,
    /// DISPLAY-HINT clause, if any.
    pub display_hint: Option<String>,
    /// DESCRIPTION clause, if any.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
    /// Whether this type was defined as a TEXTUAL-CONVENTION.
    pub is_textual_convention: bool,
    /// SIZE, range, enum, and BITS constraints.
    pub constraints: ExportConstraints,
}

/// Collected SIZE, range, enum, and BITS constraints for a type or object.
#[derive(Serialize)]
pub struct ExportConstraints {
    /// SIZE constraints (for OCTET STRING and similar).
    pub sizes: Vec<ExportRange>,
    /// Value range constraints (for INTEGER-based types).
    pub ranges: Vec<ExportRange>,
    /// Named integer enum values (e.g. `up(1)`, `down(2)`).
    pub enums: Vec<ExportEnum>,
    /// Named bit positions (e.g. `flag1(0)`, `flag2(1)`).
    pub bits: Vec<ExportBit>,
}

/// A min/max range element (SIZE or value range), serialized as strings.
#[derive(Serialize)]
pub struct ExportRange {
    pub min: String,
    pub max: String,
}

/// A named enum value, e.g. `up(1)`.
#[derive(Serialize)]
pub struct ExportEnum {
    pub name: String,
    pub value: i64,
}

/// A named bit position, e.g. `flag1(0)`.
#[derive(Serialize)]
pub struct ExportBit {
    pub name: String,
    pub position: i64,
}

/// Effective (fully resolved) syntax for an object or compliance refinement.
///
/// Contains the final base type, display hint, and constraints after
/// walking the full type chain. The `type_ref` field, when present, is
/// in `"Module::TypeName"` format.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportEffectiveSyntax {
    /// Reference to the user-defined type, if any (e.g. `"SNMPv2-TC::DisplayString"`).
    pub type_ref: Option<String>,
    /// Resolved base type name (e.g. `"OctetString"`, `"Integer32"`).
    pub base: String,
    /// DISPLAY-HINT from the type or object, if any.
    pub display_hint: Option<String>,
    /// Merged constraints from the type chain and any local refinements.
    pub constraints: ExportConstraints,
}

/// A reference to a named OID node (name, defining module, and numeric OID).
#[derive(Serialize)]
pub struct ExportOidRef {
    /// Object or node name (e.g. `"ifIndex"`).
    pub name: String,
    /// Defining module name (e.g. `"IF-MIB"`).
    pub module: String,
    /// Numeric OID string (e.g. `"1.3.6.1.2.1.2.2.1.1"`). Empty if unresolved.
    pub oid: String,
}

/// An OID tree node that is not an object, notification, group, compliance, or capability.
///
/// Covers MODULE-IDENTITY, OBJECT-IDENTITY, and plain OBJECT IDENTIFIER
/// value assignments.
#[derive(Serialize)]
pub struct ExportNode {
    /// Unique key in `"Module::NodeName"` format.
    pub key: String,
    /// Numeric OID string.
    pub oid: String,
    /// Node name (e.g. `"internet"`, `"ifMIB"`).
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// Node kind: `"module-identity"`, `"object-identity"`, or `"node"`.
    pub kind: String,
    /// STATUS clause, present only for module-identity and object-identity nodes.
    pub status: Option<String>,
    /// DESCRIPTION clause, if any.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
}

/// A resolved OBJECT-TYPE with its effective syntax, indexes, and table relationships.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportObject {
    /// Unique key in `"Module::ObjectName"` format.
    pub key: String,
    /// Numeric OID string (e.g. `"1.3.6.1.2.1.2.2.1.1"`).
    pub oid: String,
    /// Object name (e.g. `"ifIndex"`).
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// One of `"scalar"`, `"table"`, `"row"`, `"column"`, or `"object"`.
    pub kind: String,
    /// STATUS clause value (e.g. `"current"`, `"deprecated"`).
    pub status: String,
    /// MAX-ACCESS or ACCESS clause value (e.g. `"read-only"`, `"read-write"`).
    pub access: String,
    /// DESCRIPTION clause, if any.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
    /// UNITS clause, if any.
    pub units: Option<String>,
    /// DEFVAL clause, if present and non-empty.
    pub default_value: Option<ExportDefVal>,
    /// Fully resolved syntax (base type, constraints, display hint).
    pub syntax: Option<ExportEffectiveSyntax>,
    /// INDEX entries declared directly on this object.
    pub indexes: Vec<ExportIndex>,
    /// Effective indexes, including those inherited via AUGMENTS.
    pub effective_indexes: Vec<ExportIndex>,
    /// The row object this object AUGMENTS, if any.
    pub augments: Option<ExportOidRef>,
    /// Other row objects that AUGMENT this one.
    pub augmented_by: Vec<ExportOidRef>,
    /// Parent table for row and column objects.
    pub table: Option<ExportOidRef>,
    /// Row entry for table and column objects.
    pub row: Option<ExportOidRef>,
    /// Column objects for table and row objects, sorted by OID.
    pub columns: Vec<ExportOidRef>,
}

/// A resolved DEFVAL with its kind, typed value, and raw text.
///
/// The `kind` field is one of: `"int"`, `"uint"`, `"string"`, `"bytes"`,
/// `"enum"`, `"bits"`, or `"oid"`. The `value` is a JSON representation
/// appropriate for the kind (string for most, array for bits).
#[derive(Serialize)]
pub struct ExportDefVal {
    /// Value kind discriminator.
    pub kind: String,
    /// Typed value as JSON (string for scalars, array for bits).
    pub value: serde_json::Value,
    /// Original text from the MIB source.
    pub raw: String,
}

/// An INDEX entry, either object-backed or type-backed.
#[derive(Serialize)]
pub struct ExportIndex {
    /// The index object reference, if resolved.
    pub object: Option<ExportOidRef>,
    /// Whether this index is IMPLIED (variable-length last index).
    pub implied: bool,
    /// Effective syntax when the index is type-backed (no resolved object).
    pub syntax: Option<ExportEffectiveSyntax>,
}

/// A resolved NOTIFICATION-TYPE (SMIv2) or TRAP-TYPE (SMIv1).
#[derive(Serialize)]
pub struct ExportNotification {
    /// Unique key in `"Module::NotificationName"` format.
    pub key: String,
    /// Numeric OID string.
    pub oid: String,
    /// Notification name.
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// STATUS clause value.
    pub status: String,
    /// Either `"notification"` (SMIv2) or `"trap"` (SMIv1).
    pub kind: String,
    /// SMIv1 TRAP-TYPE fields, present only when `kind` is `"trap"`.
    pub trap: Option<ExportTrap>,
    /// DESCRIPTION clause text.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
    /// OBJECTS clause: the variables included with this notification.
    pub objects: Vec<ExportOidRef>,
}

/// SMIv1 TRAP-TYPE specific fields (enterprise OID and trap number).
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportTrap {
    /// ENTERPRISE clause OID reference.
    pub enterprise: ExportOidRef,
    /// Numeric trap number (the value after `::=`).
    pub trap_number: u32,
}

/// A resolved OBJECT-GROUP or NOTIFICATION-GROUP.
#[derive(Serialize)]
pub struct ExportGroup {
    /// Unique key in `"Module::GroupName"` format.
    pub key: String,
    /// Numeric OID string.
    pub oid: String,
    /// Group name.
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// Either `"object-group"` or `"notification-group"`.
    pub kind: String,
    /// STATUS clause value.
    pub status: String,
    /// DESCRIPTION clause text.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
    /// OBJECTS or NOTIFICATIONS clause members.
    pub members: Vec<ExportOidRef>,
}

/// A resolved MODULE-COMPLIANCE definition.
#[derive(Serialize)]
pub struct ExportCompliance {
    /// Unique key in `"Module::ComplianceName"` format.
    pub key: String,
    /// Numeric OID string.
    pub oid: String,
    /// Compliance object name.
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// STATUS clause value.
    pub status: String,
    /// DESCRIPTION clause text.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
    /// MODULE clauses within this compliance definition.
    pub modules: Vec<ExportComplianceModule>,
}

/// A MODULE clause within a MODULE-COMPLIANCE export.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportComplianceModule {
    /// Module name this clause applies to.
    pub module: String,
    /// Whether this clause refers to the same module that defines the compliance.
    pub is_current_module: bool,
    /// MANDATORY-GROUPS entries.
    pub mandatory_groups: Vec<ExportOidRef>,
    /// Conditionally required GROUP entries.
    pub groups: Vec<ExportComplianceGroup>,
    /// OBJECT refinement entries.
    pub objects: Vec<ExportComplianceObject>,
}

/// A conditionally required GROUP within MODULE-COMPLIANCE.
#[derive(Serialize)]
pub struct ExportComplianceGroup {
    /// Reference to the group.
    pub group: ExportOidRef,
    /// DESCRIPTION clause explaining when this group is required.
    pub description: Option<String>,
}

/// An OBJECT refinement within MODULE-COMPLIANCE.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportComplianceObject {
    /// Reference to the refined object.
    pub object: ExportOidRef,
    /// Refined SYNTAX, if narrowed from the object's original syntax.
    pub syntax: Option<ExportEffectiveSyntax>,
    /// Refined WRITE-SYNTAX, if narrowed for write operations.
    pub write_syntax: Option<ExportEffectiveSyntax>,
    /// MIN-ACCESS level, if specified.
    pub min_access: Option<String>,
    /// DESCRIPTION clause for this refinement.
    pub description: Option<String>,
}

/// A resolved AGENT-CAPABILITIES definition.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportCapability {
    /// Unique key in `"Module::CapabilityName"` format.
    pub key: String,
    /// Numeric OID string.
    pub oid: String,
    /// Capability object name.
    pub name: String,
    /// Defining module name.
    pub module: String,
    /// STATUS clause value.
    pub status: String,
    /// PRODUCT-RELEASE clause, if present.
    pub product_release: Option<String>,
    /// DESCRIPTION clause text.
    pub description: Option<String>,
    /// REFERENCE clause, if any.
    pub reference: Option<String>,
    /// SUPPORTS clauses describing which modules/groups this agent supports.
    pub supports: Vec<ExportCapabilitySupports>,
}

/// A SUPPORTS clause within AGENT-CAPABILITIES.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportCapabilitySupports {
    /// Supported module name.
    pub module: String,
    /// INCLUDES groups from this module.
    pub includes: Vec<ExportOidRef>,
    /// VARIATION clauses for objects in this module.
    pub object_variations: Vec<ExportObjectVariation>,
    /// VARIATION clauses for notifications in this module.
    pub notification_variations: Vec<ExportNotificationVariation>,
}

/// A VARIATION clause for an object within AGENT-CAPABILITIES.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportObjectVariation {
    /// Reference to the varied object.
    pub object: ExportOidRef,
    /// Refined SYNTAX, if narrowed.
    pub syntax: Option<ExportEffectiveSyntax>,
    /// Refined WRITE-SYNTAX, if narrowed for write operations.
    pub write_syntax: Option<ExportEffectiveSyntax>,
    /// Overridden ACCESS level, if different from the object's definition.
    pub access: Option<String>,
    /// CREATION-REQUIRES objects needed to create a row.
    pub creation_requires: Vec<ExportOidRef>,
    /// DEFVAL override for this variation.
    pub default_value: Option<ExportDefVal>,
    /// DESCRIPTION clause for this variation.
    pub description: Option<String>,
}

/// A VARIATION clause for a notification within AGENT-CAPABILITIES.
#[derive(Serialize)]
pub struct ExportNotificationVariation {
    /// Reference to the varied notification.
    pub notification: ExportOidRef,
    /// Overridden ACCESS level, if specified.
    pub access: Option<String>,
    /// DESCRIPTION clause for this variation.
    pub description: Option<String>,
}

/// A diagnostic message from parsing, lowering, or resolution.
#[derive(Serialize)]
pub struct ExportDiagnostic {
    /// Pipeline phase that produced this diagnostic (e.g. `"parser"`, `"resolver"`).
    pub phase: String,
    /// Machine-readable diagnostic code.
    pub code: String,
    /// One of `"error"`, `"warning"`, `"info"`, or `"style"`.
    pub severity: String,
    /// Module name where the diagnostic originated, if known.
    pub module: Option<String>,
    /// Source line number (1-based), if known.
    pub line: Option<usize>,
    /// Source column number (1-based), if known.
    pub column: Option<usize>,
    /// Human-readable diagnostic message.
    pub message: String,
}

// --- Schema-canonical string mappings ---

fn base_type_str(b: BaseType) -> &'static str {
    match b {
        BaseType::Unknown => "Unknown",
        BaseType::Integer32 => "Integer32",
        BaseType::Unsigned32 => "Unsigned32",
        BaseType::Counter32 => "Counter32",
        BaseType::Counter64 => "Counter64",
        BaseType::Gauge32 => "Gauge32",
        BaseType::TimeTicks => "TimeTicks",
        BaseType::IpAddress => "IpAddress",
        BaseType::OctetString => "OctetString",
        BaseType::ObjectIdentifier => "ObjectIdentifier",
        BaseType::Bits => "Bits",
        BaseType::Opaque => "Opaque",
        BaseType::Sequence => "Unknown",
        BaseType::Integer64 => "Integer64",
        BaseType::Unsigned64 => "Unsigned64",
    }
}

fn status_str(s: Status) -> &'static str {
    match s {
        Status::Current => "current",
        Status::Deprecated => "deprecated",
        Status::Obsolete => "obsolete",
        Status::Mandatory => "mandatory",
        Status::Optional => "optional",
    }
}

fn access_str(a: Access) -> &'static str {
    match a {
        Access::NotAccessible => "not-accessible",
        Access::AccessibleForNotify => "accessible-for-notify",
        Access::ReadOnly => "read-only",
        Access::ReadWrite => "read-write",
        Access::ReadCreate => "read-create",
        Access::WriteOnly => "write-only",
        Access::NotImplemented => "not-implemented",
    }
}

fn language_str(l: Language) -> Option<&'static str> {
    match l {
        Language::Unknown => None,
        Language::SMIv1 => Some("SMIv1"),
        Language::SMIv2 => Some("SMIv2"),
        Language::SPPI => Some("SPPI"),
    }
}

fn severity_str(s: Severity) -> &'static str {
    match s {
        Severity::Fatal | Severity::Severe | Severity::Error => "error",
        Severity::Minor => "warning",
        Severity::Warning => "warning",
        Severity::Info => "info",
        Severity::Style => "style",
    }
}

fn object_kind_str(k: Kind) -> &'static str {
    match k {
        Kind::Scalar => "scalar",
        Kind::Table => "table",
        Kind::Row => "row",
        Kind::Column => "column",
        _ => "object",
    }
}

fn opt_string(s: &str) -> Option<String> {
    if s.is_empty() {
        None
    } else {
        Some(s.to_string())
    }
}

fn is_user_defined_type(mib: &Mib, td: &TypeData) -> bool {
    if td.is_textual_convention() {
        return true;
    }
    !matches!(td.module(), Some(mid) if mib.raw().module(mid).is_base())
}

// --- Export building ---

fn make_constraints(
    sizes: &[Range],
    ranges: &[Range],
    enums: &[NamedValue],
    bits: &[NamedValue],
) -> ExportConstraints {
    ExportConstraints {
        sizes: sizes
            .iter()
            .map(|r| ExportRange {
                min: r.min.to_string(),
                max: r.max.to_string(),
            })
            .collect(),
        ranges: ranges
            .iter()
            .map(|r| ExportRange {
                min: r.min.to_string(),
                max: r.max.to_string(),
            })
            .collect(),
        enums: enums
            .iter()
            .map(|e| ExportEnum {
                name: e.label.clone(),
                value: e.value,
            })
            .collect(),
        bits: bits
            .iter()
            .map(|b| ExportBit {
                name: b.label.clone(),
                position: b.value,
            })
            .collect(),
    }
}

fn make_oid_ref(name: &str, module: &str, oid: &str) -> ExportOidRef {
    ExportOidRef {
        name: name.to_string(),
        module: module.to_string(),
        oid: oid.to_string(),
    }
}

fn resolve_object_ref(mib: &Mib, name: &str, fallback_module: &str) -> ExportOidRef {
    if let Some(obj_id) = mib.object_by_name(name) {
        let obj = mib.raw().object(obj_id);
        let mod_name = obj
            .module()
            .map(|mid| mib.raw().module(mid).name())
            .unwrap_or("");
        let oid_str = obj
            .node()
            .map(|nid| mib.tree().oid_of(nid).to_string())
            .unwrap_or_default();
        make_oid_ref(name, mod_name, &oid_str)
    } else {
        make_oid_ref(name, fallback_module, "")
    }
}

fn resolve_node_ref(mib: &Mib, name: &str) -> ExportOidRef {
    resolve_node_ref_with_fallback(mib, name, "")
}

fn resolve_node_ref_with_fallback(mib: &Mib, name: &str, fallback_module: &str) -> ExportOidRef {
    if let Some(node_id) = mib.node_by_name(name) {
        let node = mib.tree().get(node_id);
        let mod_name = mib
            .effective_module(node_id)
            .map(|mid| mib.raw().module(mid).name())
            .unwrap_or("");
        let oid_str = mib.tree().oid_of(node_id).to_string();
        make_oid_ref(
            if node.name().is_empty() {
                name
            } else {
                node.name()
            },
            mod_name,
            &oid_str,
        )
    } else {
        make_oid_ref(name, fallback_module, "")
    }
}

fn resolve_notification_ref(mib: &Mib, name: &str, fallback_module: &str) -> ExportOidRef {
    if let Some(notif_id) = mib.notification_by_name(name) {
        let notif = mib.raw().notification(notif_id);
        let mod_name = notif
            .module()
            .map(|mid| mib.raw().module(mid).name())
            .unwrap_or("");
        let oid_str = notif
            .node()
            .map(|nid| mib.tree().oid_of(nid).to_string())
            .unwrap_or_default();
        make_oid_ref(name, mod_name, &oid_str)
    } else {
        // Fall back to generic node lookup (the variation may reference a
        // non-notification object that the syntactic heuristic classified
        // as a notification variation).
        resolve_node_ref_with_fallback(mib, name, fallback_module)
    }
}

fn object_id_to_ref(mib: &Mib, obj_id: ObjectId) -> ExportOidRef {
    let obj = mib.raw().object(obj_id);
    let mod_name = obj
        .module()
        .map(|mid| mib.raw().module(mid).name())
        .unwrap_or("");
    let oid_str = obj
        .node()
        .map(|nid| mib.tree().oid_of(nid).to_string())
        .unwrap_or_default();
    make_oid_ref(obj.name(), mod_name, &oid_str)
}

fn make_defval(dv: &DefVal) -> Option<ExportDefVal> {
    let (kind_str, value) = match &dv.value {
        DefValValue::None => return None,
        DefValValue::Int(v) => ("int", serde_json::Value::String(v.to_string())),
        DefValValue::Uint(v) => ("uint", serde_json::Value::String(v.to_string())),
        DefValValue::String(v) => ("string", serde_json::Value::String(v.clone())),
        DefValValue::Bytes(b) => {
            let hex: String = b.iter().map(|byte| format!("{byte:02X}")).collect();
            ("bytes", serde_json::Value::String(hex))
        }
        DefValValue::Enum(label) => ("enum", serde_json::Value::String(label.clone())),
        DefValValue::Bits(labels) => {
            let arr: Vec<serde_json::Value> = labels
                .iter()
                .map(|s| serde_json::Value::String(s.clone()))
                .collect();
            ("bits", serde_json::Value::Array(arr))
        }
        DefValValue::Oid(oid) => ("oid", serde_json::Value::String(oid.to_string())),
    };
    Some(ExportDefVal {
        kind: kind_str.to_string(),
        value,
        raw: dv.raw().to_string(),
    })
}

fn make_effective_syntax_from_object(mib: &Mib, obj_id: ObjectId) -> Option<ExportEffectiveSyntax> {
    let obj = mib.raw().object(obj_id);
    let type_id = obj.type_id()?;
    let td = mib.raw().type_(type_id);
    let types = mib.types_slice();
    let base = td.effective_base(types);

    let type_ref = if td.name().is_empty() || !is_user_defined_type(mib, td) {
        None
    } else {
        let mod_name = td
            .module()
            .map(|mid| mib.raw().module(mid).name())
            .unwrap_or("");
        Some(format!("{mod_name}::{}", td.name()))
    };

    let display_hint = {
        let h = obj.effective_display_hint();
        if h.is_empty() {
            let h2 = td.effective_display_hint(types);
            opt_string(h2)
        } else {
            Some(h.to_string())
        }
    };

    Some(ExportEffectiveSyntax {
        type_ref,
        base: base_type_str(base).to_string(),
        display_hint,
        constraints: make_constraints(
            obj.effective_sizes(),
            obj.effective_ranges(),
            obj.effective_enums(),
            obj.effective_bits(),
        ),
    })
}

fn make_syntax_constraints(mib: &Mib, sc: &SyntaxConstraints) -> ExportEffectiveSyntax {
    let (type_ref, base, display_hint) = if let Some(tid) = sc.type_id {
        let td = mib.raw().type_(tid);
        let types = mib.types_slice();
        let mod_name = td
            .module()
            .map(|mid| mib.raw().module(mid).name())
            .unwrap_or("");
        let tr = if td.name().is_empty() || !is_user_defined_type(mib, td) {
            None
        } else {
            Some(format!("{mod_name}::{}", td.name()))
        };
        let b = td.effective_base(types);
        let h = td.effective_display_hint(types);
        (tr, b, opt_string(h))
    } else {
        (None, BaseType::Unknown, None)
    };

    ExportEffectiveSyntax {
        type_ref,
        base: base_type_str(base).to_string(),
        display_hint,
        constraints: make_constraints(&sc.sizes, &sc.ranges, &sc.enums, &sc.bits),
    }
}

/// Compare two OIDs numerically for sorting.
fn cmp_oid(a: &Oid, b: &Oid) -> Ordering {
    for (aa, bb) in a.iter().zip(b.iter()) {
        match aa.cmp(bb) {
            Ordering::Equal => continue,
            other => return other,
        }
    }
    a.len().cmp(&b.len())
}

fn sort_keyed_values<K, T, F>(mut items: Vec<(K, T)>, mut cmp: F) -> Vec<T>
where
    F: FnMut(&(K, T), &(K, T)) -> Ordering,
{
    items.sort_by(|a, b| cmp(a, b));
    items.into_iter().map(|(_, value)| value).collect()
}

fn sort_oid_keyed_values<T, F>(items: Vec<(Oid, T)>, mut tie_break: F) -> Vec<T>
where
    F: FnMut(&T, &T) -> Ordering,
{
    sort_keyed_values(items, |a, b| {
        cmp_oid(&a.0, &b.0).then_with(|| tie_break(&a.1, &b.1))
    })
}

/// Build the complete export payload from a resolved [`Mib`].
///
/// All collections are sorted deterministically (modules and types by name,
/// objects/notifications/groups by OID) for reproducible output. The
/// `strictness` parameter is recorded in the payload metadata but does not
/// affect the export itself.
///
/// # Examples
///
/// ```no_run
/// use mib_rs::{Loader, export};
/// use mib_rs::types::ResolverStrictness;
///
/// let mib = Loader::new()
///     .system_paths()
///     .modules(["IF-MIB"])
///     .load()
///     .unwrap();
/// let payload = export::export_payload(&mib, ResolverStrictness::Normal);
/// let json = serde_json::to_string_pretty(&payload).unwrap();
/// ```
pub fn export_payload(mib: &Mib, strictness: ResolverStrictness) -> ExportPayload {
    let tree = mib.tree();

    // --- Modules ---
    let mut modules: Vec<ExportModule> = mib
        .modules_slice()
        .iter()
        .map(|m| ExportModule {
            name: m.name().to_string(),
            oid: m.oid().map(|o| o.to_string()),
            language: language_str(m.language()).map(|s| s.to_string()),
            organization: opt_string(m.organization()),
            contact_info: opt_string(m.contact_info()),
            description: opt_string(m.description()),
            last_updated: opt_string(m.last_updated()),
            revisions: m
                .revisions()
                .iter()
                .map(|r| ExportRevision {
                    date: r.date.clone(),
                    description: opt_string(&r.description),
                })
                .collect(),
        })
        .collect();
    modules.sort_by(|a, b| a.name.cmp(&b.name));

    // --- Types ---
    let mut types: Vec<ExportType> = mib
        .types_slice()
        .iter()
        .map(|t| {
            let mod_name = t
                .module()
                .map(|mid| mib.raw().module(mid).name())
                .unwrap_or("");
            let parent = t.parent().and_then(|pid| {
                let pt = mib.raw().type_(pid);
                if pt.name().is_empty() || !is_user_defined_type(mib, pt) {
                    return None;
                }
                let pm = pt
                    .module()
                    .map(|mid| mib.raw().module(mid).name())
                    .unwrap_or("");
                Some(format!("{pm}::{}", pt.name()))
            });
            let all_types = mib.types_slice();
            let eff_base = t.effective_base(all_types);

            ExportType {
                key: format!("{mod_name}::{}", t.name()),
                name: t.name().to_string(),
                module: mod_name.to_string(),
                parent,
                base: base_type_str(eff_base).to_string(),
                status: if t.is_textual_convention() {
                    Some(status_str(t.status()).to_string())
                } else {
                    None
                },
                display_hint: opt_string(t.display_hint()),
                description: opt_string(t.description()),
                reference: opt_string(t.reference()),
                is_textual_convention: t.is_textual_convention(),
                constraints: make_constraints(t.sizes(), t.ranges(), t.enums(), t.bits()),
            }
        })
        .collect();
    types.sort_by(|a, b| a.module.cmp(&b.module).then(a.name.cmp(&b.name)));

    // --- Nodes ---
    // Nodes are plain OID-bearing definitions that are NOT objects, notifications,
    // groups, compliances, or capabilities.
    let mut nodes: Vec<ExportNode> = Vec::new();
    for node_id in tree.all_nodes() {
        let nd = tree.get(node_id);
        if nd.name().is_empty() {
            continue;
        }
        // Skip nodes that have attached entities exported elsewhere
        if nd.object.is_some()
            || nd.notification.is_some()
            || nd.group.is_some()
            || nd.compliance.is_some()
            || nd.capability.is_some()
        {
            continue;
        }
        let mod_id = mib.effective_module(node_id);
        let mod_name = mod_id.map(|mid| mib.raw().module(mid).name()).unwrap_or("");
        let oid_str = tree.oid_of(node_id).to_string();

        let kind = match nd.kind() {
            Kind::ModuleIdentity => "module-identity",
            Kind::ObjectIdentity => "object-identity",
            _ => "node",
        };

        let status = match nd.kind() {
            Kind::ModuleIdentity | Kind::ObjectIdentity => {
                nd.status().map(|s| status_str(s).to_string())
            }
            _ => None,
        };

        nodes.push(ExportNode {
            key: format!("{mod_name}::{}", nd.name()),
            oid: oid_str,
            name: nd.name().to_string(),
            module: mod_name.to_string(),
            kind: kind.to_string(),
            status,
            description: opt_string(nd.description()),
            reference: opt_string(nd.reference()),
        });
    }
    nodes.sort_by(|a, b| a.module.cmp(&b.module).then(a.name.cmp(&b.name)));

    // --- Objects ---
    let mut objects: Vec<(Oid, ExportObject)> = Vec::new();
    for (i, obj) in mib.objects_slice().iter().enumerate() {
        let obj_id = ObjectId::new(i as u32);
        let node_id = match obj.node() {
            Some(id) => id,
            None => continue,
        };
        let mod_id = obj.module();
        let mod_name = mod_id.map(|mid| mib.raw().module(mid).name()).unwrap_or("");
        let oid = tree.oid_of(node_id).clone();
        let oid_str = oid.to_string();
        let kind = obj.kind(tree);

        let syntax = make_effective_syntax_from_object(mib, obj_id);

        let default_value = obj
            .default_value()
            .and_then(|dv| if dv.is_unset() { None } else { make_defval(dv) });

        let indexes: Vec<ExportIndex> = obj
            .index()
            .iter()
            .map(|idx| make_index_entry(mib, idx))
            .collect();

        let eff_indexes: Vec<ExportIndex> = mib
            .effective_indexes(obj_id)
            .iter()
            .map(|idx| make_index_entry(mib, idx))
            .collect();

        let augments = obj.augments().map(|aid| object_id_to_ref(mib, aid));

        let augmented_by: Vec<ExportOidRef> = obj
            .augmented_by()
            .iter()
            .map(|&aid| object_id_to_ref(mib, aid))
            .collect();

        let table = match kind {
            Kind::Row | Kind::Column => mib
                .object_table(obj_id)
                .map(|tid| object_id_to_ref(mib, tid)),
            _ => None,
        };

        let row = match kind {
            Kind::Table => mib.object_row(obj_id).map(|rid| object_id_to_ref(mib, rid)),
            Kind::Column => mib.object_row(obj_id).map(|rid| object_id_to_ref(mib, rid)),
            _ => None,
        };

        let columns: Vec<ExportOidRef> = match kind {
            Kind::Table | Kind::Row => {
                let cols: Vec<(Oid, ExportOidRef)> = mib
                    .object_columns(obj_id)
                    .into_iter()
                    .map(|cid| {
                        let col_oid = mib
                            .raw()
                            .object(cid)
                            .node()
                            .map(|nid| tree.oid_of(nid).clone())
                            .unwrap_or_default();
                        (col_oid, object_id_to_ref(mib, cid))
                    })
                    .collect();
                sort_oid_keyed_values(cols, |a, b| {
                    a.module.cmp(&b.module).then(a.name.cmp(&b.name))
                })
            }
            _ => Vec::new(),
        };

        // Sort augmented_by by OID
        let augmented_sorted: Vec<(Oid, ExportOidRef)> = augmented_by
            .into_iter()
            .map(|r| {
                let o: Oid = r.oid.parse().unwrap_or_default();
                (o, r)
            })
            .collect();
        let augmented_by = sort_oid_keyed_values(augmented_sorted, |a, b| {
            a.module.cmp(&b.module).then(a.name.cmp(&b.name))
        });

        objects.push((
            oid.clone(),
            ExportObject {
                key: format!("{mod_name}::{}", obj.name()),
                oid: oid_str,
                name: obj.name().to_string(),
                module: mod_name.to_string(),
                kind: object_kind_str(kind).to_string(),
                status: status_str(obj.status()).to_string(),
                access: access_str(obj.access()).to_string(),
                description: opt_string(obj.description()),
                reference: opt_string(obj.reference()),
                units: opt_string(obj.units()),
                default_value,
                syntax,
                indexes,
                effective_indexes: eff_indexes,
                augments,
                augmented_by,
                table,
                row,
                columns,
            },
        ));
    }
    let objects = sort_oid_keyed_values(objects, |a, b| {
        a.module.cmp(&b.module).then(a.name.cmp(&b.name))
    });

    // --- Notifications ---
    let mut notifications: Vec<(Oid, ExportNotification)> = Vec::new();
    for notif in mib.notifications_slice() {
        let mod_id = notif.module();
        let mod_name = mod_id.map(|mid| mib.raw().module(mid).name()).unwrap_or("");
        let oid = notif
            .node()
            .map(|nid| tree.oid_of(nid).clone())
            .unwrap_or_default();
        let oid_str = oid.to_string();

        let kind = if notif.trap_info().is_some() {
            "trap"
        } else {
            "notification"
        };

        let trap = notif.trap_info().map(|ti| {
            let enterprise_ref = resolve_node_ref(mib, &ti.enterprise);
            ExportTrap {
                enterprise: enterprise_ref,
                trap_number: ti.trap_number,
            }
        });

        let notif_objects: Vec<ExportOidRef> = notif
            .objects()
            .iter()
            .map(|&oid| object_id_to_ref(mib, oid))
            .collect();

        notifications.push((
            oid.clone(),
            ExportNotification {
                key: format!("{mod_name}::{}", notif.name()),
                oid: oid_str,
                name: notif.name().to_string(),
                module: mod_name.to_string(),
                status: status_str(notif.status()).to_string(),
                kind: kind.to_string(),
                trap,
                description: opt_string(notif.description()),
                reference: opt_string(notif.reference()),
                objects: notif_objects,
            },
        ));
    }
    let notifications = sort_oid_keyed_values(notifications, |a, b| {
        a.module
            .cmp(&b.module)
            .then(a.name.cmp(&b.name))
            // "notification" < "trap" alphabetically, preferring NOTIFICATION-TYPE over TRAP-TYPE
            .then(a.kind.cmp(&b.kind))
    });

    // --- Groups ---
    let mut groups: Vec<(Oid, ExportGroup)> = Vec::new();
    for group in mib.groups_slice() {
        let mod_id = group.module();
        let mod_name = mod_id.map(|mid| mib.raw().module(mid).name()).unwrap_or("");
        let oid = group
            .node()
            .map(|nid| tree.oid_of(nid).clone())
            .unwrap_or_default();
        let oid_str = oid.to_string();

        let kind = if group.is_notification_group() {
            "notification-group"
        } else {
            "object-group"
        };

        let members: Vec<ExportOidRef> = group
            .members()
            .iter()
            .map(|&nid| {
                let nd = tree.get(nid);
                let m = mib
                    .effective_module(nid)
                    .map(|mid| mib.raw().module(mid).name())
                    .unwrap_or("");
                make_oid_ref(nd.name(), m, &tree.oid_of(nid).to_string())
            })
            .collect();

        groups.push((
            oid.clone(),
            ExportGroup {
                key: format!("{mod_name}::{}", group.name()),
                oid: oid_str,
                name: group.name().to_string(),
                module: mod_name.to_string(),
                kind: kind.to_string(),
                status: status_str(group.status()).to_string(),
                description: opt_string(group.description()),
                reference: opt_string(group.reference()),
                members,
            },
        ));
    }
    let groups = sort_oid_keyed_values(groups, |a, b| {
        a.module
            .cmp(&b.module)
            .then(a.name.cmp(&b.name))
            // Prefer larger groups first for duplicate keys
            .then(b.members.len().cmp(&a.members.len()))
    });

    // --- Compliances ---
    let mut compliances: Vec<(Oid, ExportCompliance)> = Vec::new();
    for comp in mib.compliances_slice() {
        let mod_id = comp.module();
        let comp_mod_name = mod_id.map(|mid| mib.raw().module(mid).name()).unwrap_or("");
        let oid = comp
            .node()
            .map(|nid| tree.oid_of(nid).clone())
            .unwrap_or_default();
        let oid_str = oid.to_string();

        let comp_modules: Vec<ExportComplianceModule> = comp
            .modules()
            .iter()
            .map(|cm| {
                let is_current = cm.module_name.is_empty() || cm.module_name == comp_mod_name;
                let effective_module = if cm.module_name.is_empty() {
                    comp_mod_name.to_string()
                } else {
                    cm.module_name.clone()
                };

                let mandatory_groups: Vec<ExportOidRef> = cm
                    .mandatory_groups
                    .iter()
                    .map(|name| resolve_node_ref_with_fallback(mib, name, &effective_module))
                    .collect();

                let grps: Vec<ExportComplianceGroup> = cm
                    .groups
                    .iter()
                    .map(|cg| ExportComplianceGroup {
                        group: resolve_node_ref_with_fallback(mib, &cg.group, &effective_module),
                        description: opt_string(&cg.description),
                    })
                    .collect();

                let objs: Vec<ExportComplianceObject> = cm
                    .objects
                    .iter()
                    .map(|co| ExportComplianceObject {
                        object: resolve_object_ref(mib, &co.object, &effective_module),
                        syntax: co
                            .syntax
                            .as_ref()
                            .map(|sc| make_syntax_constraints(mib, sc)),
                        write_syntax: co
                            .write_syntax
                            .as_ref()
                            .map(|sc| make_syntax_constraints(mib, sc)),
                        min_access: co.min_access.map(|a| access_str(a).to_string()),
                        description: opt_string(&co.description),
                    })
                    .collect();

                ExportComplianceModule {
                    module: effective_module,
                    is_current_module: is_current,
                    mandatory_groups,
                    groups: grps,
                    objects: objs,
                }
            })
            .collect();

        compliances.push((
            oid.clone(),
            ExportCompliance {
                key: format!("{comp_mod_name}::{}", comp.name()),
                oid: oid_str,
                name: comp.name().to_string(),
                module: comp_mod_name.to_string(),
                status: status_str(comp.status()).to_string(),
                description: opt_string(comp.description()),
                reference: opt_string(comp.reference()),
                modules: comp_modules,
            },
        ));
    }
    let compliances = sort_oid_keyed_values(compliances, |a, b| {
        a.module.cmp(&b.module).then(a.name.cmp(&b.name))
    });

    // --- Capabilities ---
    let mut capabilities: Vec<(Oid, ExportCapability)> = Vec::new();
    for cap in mib.capabilities_slice() {
        let mod_id = cap.module();
        let mod_name = mod_id.map(|mid| mib.raw().module(mid).name()).unwrap_or("");
        let oid = cap
            .node()
            .map(|nid| tree.oid_of(nid).clone())
            .unwrap_or_default();
        let oid_str = oid.to_string();

        let supports: Vec<ExportCapabilitySupports> = cap
            .supports()
            .iter()
            .map(|sm| {
                let includes: Vec<ExportOidRef> = sm
                    .includes
                    .iter()
                    .map(|name| resolve_node_ref_with_fallback(mib, name, &sm.module_name))
                    .collect();

                let obj_vars: Vec<ExportObjectVariation> =
                    sm.object_variations
                        .iter()
                        .map(|ov| {
                            let creation_req: Vec<ExportOidRef> = ov
                                .creation_requires
                                .iter()
                                .map(|name| resolve_object_ref(mib, name, &sm.module_name))
                                .collect();

                            ExportObjectVariation {
                                object: resolve_object_ref(mib, &ov.object, &sm.module_name),
                                syntax: ov
                                    .syntax
                                    .as_ref()
                                    .map(|sc| make_syntax_constraints(mib, sc)),
                                write_syntax: ov
                                    .write_syntax
                                    .as_ref()
                                    .map(|sc| make_syntax_constraints(mib, sc)),
                                access: ov.access.map(|a| access_str(a).to_string()),
                                creation_requires: creation_req,
                                default_value: ov.def_val.as_ref().and_then(|dv| {
                                    if dv.is_unset() { None } else { make_defval(dv) }
                                }),
                                description: opt_string(&ov.description),
                            }
                        })
                        .collect();

                let notif_vars: Vec<ExportNotificationVariation> = sm
                    .notification_variations
                    .iter()
                    .map(|nv| ExportNotificationVariation {
                        notification: resolve_notification_ref(
                            mib,
                            &nv.notification,
                            &sm.module_name,
                        ),
                        access: nv.access.map(|a| access_str(a).to_string()),
                        description: opt_string(&nv.description),
                    })
                    .collect();

                ExportCapabilitySupports {
                    module: sm.module_name.clone(),
                    includes,
                    object_variations: obj_vars,
                    notification_variations: notif_vars,
                }
            })
            .collect();

        capabilities.push((
            oid.clone(),
            ExportCapability {
                key: format!("{mod_name}::{}", cap.name()),
                oid: oid_str,
                name: cap.name().to_string(),
                module: mod_name.to_string(),
                status: status_str(cap.status()).to_string(),
                product_release: opt_string(cap.product_release()),
                description: opt_string(cap.description()),
                reference: opt_string(cap.reference()),
                supports,
            },
        ));
    }
    let capabilities = sort_oid_keyed_values(capabilities, |a, b| {
        a.module.cmp(&b.module).then(a.name.cmp(&b.name))
    });

    // --- Diagnostics ---
    let mut diagnostics: Vec<ExportDiagnostic> = mib
        .diagnostics()
        .iter()
        .map(|d| {
            let phase = d.code.phase().to_string();
            ExportDiagnostic {
                phase,
                code: d.code.as_code().to_string(),
                severity: severity_str(d.severity).to_string(),
                module: d.module.clone(),
                line: d.line,
                column: d.column,
                message: d.message.replace("\r\n", "\n"),
            }
        })
        .collect();
    diagnostics.sort_by(|a, b| {
        a.phase
            .cmp(&b.phase)
            .then(a.code.cmp(&b.code))
            .then(a.severity.cmp(&b.severity))
            .then(a.module.cmp(&b.module))
            .then(a.line.cmp(&b.line))
            .then(a.column.cmp(&b.column))
            .then(a.message.cmp(&b.message))
    });

    ExportPayload {
        schema_version: 1,
        export_kind: "resolved-mib",
        strictness: strictness.to_string(),
        exporter: Exporter {
            implementation: "mib-rs",
            version: String::new(),
            commit: String::new(),
        },
        modules,
        types,
        nodes,
        objects,
        notifications,
        groups,
        compliances,
        capabilities,
        diagnostics,
    }
}

fn make_index_entry(mib: &Mib, idx: &IndexEntry) -> ExportIndex {
    match idx.object {
        Some(oid) => ExportIndex {
            object: Some(object_id_to_ref(mib, oid)),
            implied: idx.implied,
            syntax: None,
        },
        None => {
            // Type-backed index - build effective syntax from type name lookup
            let syntax = if let Some(tid) = idx.type_id {
                let td = mib.raw().type_(tid);
                let types = mib.types_slice();
                let base = td.effective_base(types);
                let mod_name = td
                    .module()
                    .map(|mid| mib.raw().module(mid).name())
                    .unwrap_or("");
                let type_ref = if td.name().is_empty() {
                    None
                } else {
                    Some(format!("{mod_name}::{}", td.name()))
                };
                Some(ExportEffectiveSyntax {
                    type_ref,
                    base: base_type_str(base).to_string(),
                    display_hint: opt_string(td.effective_display_hint(types)),
                    constraints: make_constraints(
                        td.effective_sizes(types),
                        td.effective_ranges(types),
                        td.effective_enums(types),
                        td.effective_bits(types),
                    ),
                })
            } else {
                Some(ExportEffectiveSyntax {
                    type_ref: None,
                    base: "Unknown".to_string(),
                    display_hint: None,
                    constraints: make_constraints(&[], &[], &[], &[]),
                })
            };
            ExportIndex {
                object: None,
                implied: idx.implied,
                syntax,
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use crate::load::{Loader, load};
    use crate::source::dir as dir_source;
    use crate::types::{DiagnosticConfig, ResolverStrictness};

    use super::export_payload;

    fn corpus_dir() -> PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/corpus/primary")
    }

    fn load_corpus(modules: &[&str]) -> crate::mib::Mib {
        let src = dir_source(corpus_dir()).expect("failed to create corpus source");
        let opts = Loader::new()
            .source(src)
            .resolver_strictness(ResolverStrictness::Permissive)
            .diagnostic_config(DiagnosticConfig::silent())
            .modules(modules.iter().copied());
        load(opts).expect("load failed")
    }

    #[test]
    fn export_includes_base_modules_and_builtins() {
        let mib = load_corpus(&["IF-MIB"]);
        let payload = export_payload(&mib, ResolverStrictness::Permissive);

        assert!(
            payload.modules.iter().any(|m| m.name == "SNMPv2-SMI"),
            "expected SNMPv2-SMI in exported modules"
        );
        assert!(
            payload.types.iter().any(|t| t.module == "SNMPv2-SMI"),
            "expected SNMPv2-SMI types in exported types"
        );
        assert!(
            payload
                .nodes
                .iter()
                .any(|n| n.module == "SNMPv2-SMI" && n.name == "internet"),
            "expected SNMPv2-SMI::internet in exported nodes"
        );
    }

    #[test]
    fn export_uppercases_byte_defval_hex() {
        let mib = load_corpus(&["SYNTHETIC-MIB"]);
        let payload = export_payload(&mib, ResolverStrictness::Permissive);
        let object = payload
            .objects
            .iter()
            .find(|obj| obj.name == "syntheticDefvalHex")
            .expect("syntheticDefvalHex not exported");
        let defval = object
            .default_value
            .as_ref()
            .expect("syntheticDefvalHex missing default value");

        assert_eq!(defval.kind, "bytes");
        assert_eq!(
            defval.value,
            serde_json::Value::String("DEADBEEF".to_string())
        );
    }
}
