//! Encodes an [`HwpxStyleStore`] into `header.xml` content.
//!
//! This is the reverse of [`crate::decoder::header::parse_header`]:
//! it converts Foundation types (`Color`, `HwpUnit`, `Alignment`) back
//! into the `Hx*` schema types and serializes them to XML via quick-xml.

use hwpforge_core::NumberingDef;
use hwpforge_foundation::{
    Alignment, Color, EmphasisType, HwpUnit, LineSpacingType, NumberFormatType, OutlineType,
    ShadowType, StrikeoutShape, UnderlineType,
};

use super::{escape_xml, header_tabs::build_tab_properties_xml};
use crate::error::{HwpxError, HwpxResult};
use crate::list_bridge::{bullet_def_to_hwpx, wire_parts_to_heading};
use crate::schema::header::{
    HxAlign, HxAutoSpacing, HxBorder, HxBreakSetting, HxBullet, HxBulletParaHead, HxCharPr,
    HxCharProperties, HxFont, HxFontFaceGroup, HxFontFaces, HxFontRef, HxHead, HxLangValues,
    HxLineSpacing, HxMargin, HxOutline, HxParaPr, HxParaProperties, HxPresence, HxRefList,
    HxShadow, HxStrikeout, HxStyle, HxStyles, HxSwitch, HxSwitchCase, HxSwitchDefault, HxTypeInfo,
    HxUnderline, HxUnitValue,
};
use crate::style_store::{
    ActiveBorderFillBrush, HwpxBorderFill, HwpxBorderLine, HwpxCharShape, HwpxDiagonalLine,
    HwpxFont, HwpxGradientFill, HwpxImageFill, HwpxParaShape, HwpxStyle, HwpxStyleStore,
};

// ── Public entry point ──────────────────────────────────────────

/// Encodes an [`HwpxStyleStore`] into a complete `header.xml` string.
///
/// The output includes the XML declaration, the `<hh:head>` root element
/// with all HWPX namespace declarations, and the `<hh:refList>` content
/// built from the store's fonts, character shapes, and paragraph shapes.
///
/// # Errors
///
/// Returns [`HwpxError::XmlSerialize`] if quick-xml serialization fails.
pub(crate) fn encode_header(
    store: &HwpxStyleStore,
    sec_cnt: u32,
    begin_num: Option<&hwpforge_core::section::BeginNum>,
) -> HwpxResult<String> {
    let head = build_head(store, sec_cnt);
    let head_xml = quick_xml::se::to_string(&head)
        .map_err(|e| HwpxError::XmlSerialize { detail: e.to_string() })?;

    // quick_xml serializes HxHead as `<head version="..." secCnt="...">...</head>`.
    // We need to extract the inner content and wrap it in our xmlns-decorated
    // root element instead.
    let inner = extract_inner_content(&head_xml);
    wrap_header_xml(inner, sec_cnt, store, begin_num)
}

// ── XML wrapper ─────────────────────────────────────────────────

/// Wraps inner XML content in the `<hh:head>` root element with xmlns
/// declarations.
///
/// quick-xml's serde serializer cannot emit xmlns attributes, so we
/// hand-craft the root element and splice in the serialized content.
///
/// Also injects `<hh:beginNum>` (required by 한글) before the refList,
/// and enriches the refList with `<hh:borderFills>` and `<hh:tabProperties>`
/// that charPr/paraPr reference via `borderFillIDRef` and `tabPrIDRef`.
/// Elements required after `</hh:refList>` for 한글 compatibility.
///
/// - `compatibleDocument` — declares target program compatibility.
/// - `docOption` — document link/inheritance settings.
/// - `trackchageConfig` — track-changes flags (note: "trackchage" is an
///   intentional typo preserved from the official format).
const POST_REFLIST_XML: &str = concat!(
    r#"<hh:compatibleDocument targetProgram="HWP201X">"#,
    r#"<hh:layoutCompatibility/>"#,
    r#"</hh:compatibleDocument>"#,
    r#"<hh:docOption>"#,
    r#"<hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/>"#,
    r#"</hh:docOption>"#,
    r#"<hh:trackchageConfig flags="56"/>"#,
);

fn wrap_header_xml(
    inner_xml: &str,
    sec_cnt: u32,
    store: &HwpxStyleStore,
    begin_num: Option<&hwpforge_core::section::BeginNum>,
) -> HwpxResult<String> {
    let enriched = enrich_ref_list(inner_xml, store)?;
    let begin_num_xml = build_begin_num_xml(begin_num);
    Ok(format!(
        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hh:head{xmlns} version="1.4" secCnt="{sec_cnt}">{begin_num_xml}{enriched}{post_reflist}</hh:head>"#,
        xmlns = crate::encoder::package::XMLNS_DECLS,
        post_reflist = POST_REFLIST_XML,
    ))
}

// ── 한글 compatibility defaults ─────────────────────────────────

/// Builds `<hh:beginNum>` XML from Core `BeginNum`, defaulting to all 1s.
fn build_begin_num_xml(begin_num: Option<&hwpforge_core::section::BeginNum>) -> String {
    let bn = begin_num.copied().unwrap_or_default();
    format!(
        r#"<hh:beginNum page="{}" footnote="{}" endnote="{}" pic="{}" tbl="{}" equation="{}"/>"#,
        bn.page, bn.footnote, bn.endnote, bn.pic, bn.tbl, bn.equation,
    )
}

/// Generates the `<hh:borderFills>` XML dynamically from the store's border fills.
///
/// `borderFillIDRef="1"` is referenced by `<hp:pageBorderFill>` in secPr.
/// `borderFillIDRef="2"` is referenced by every `<hh:charPr>`.
/// `borderFillIDRef="3"` is referenced by table cells (`<hp:tbl>` / `<hp:tc>`).
///
/// If the store has no border fills (e.g. a manually constructed store that
/// did not go through `from_registry`), the 3 standard defaults are emitted
/// to maintain backward compatibility with 한글.
fn build_border_fills_xml(store: &HwpxStyleStore) -> String {
    if store.border_fill_count() == 0 {
        let page = HwpxBorderFill::default_page_border();
        let char_bg = HwpxBorderFill::default_char_background();
        let table = HwpxBorderFill::default_table_border();
        let count = 3u32;
        let mut xml = format!(r##"<hh:borderFills itemCnt="{count}">"##);
        for bf in [&page, &char_bg, &table] {
            xml.push_str(&build_border_fill_xml(bf));
        }
        xml.push_str("</hh:borderFills>");
        return xml;
    }

    let count = store.border_fill_count();
    let mut xml = format!(r##"<hh:borderFills itemCnt="{count}">"##);
    for bf in store.iter_border_fills() {
        xml.push_str(&build_border_fill_xml(bf));
    }
    xml.push_str("</hh:borderFills>");
    xml
}

/// Serializes a single [`HwpxBorderFill`] to its XML representation.
fn build_border_fill_xml(bf: &HwpxBorderFill) -> String {
    let three_d = u32::from(bf.three_d);
    let shadow = u32::from(bf.shadow);
    let mut xml = format!(
        r##"<hh:borderFill id="{}" threeD="{three_d}" shadow="{shadow}" centerLine="{}" breakCellSeparateLine="0">"##,
        bf.id, bf.center_line,
    );
    xml.push_str(&build_diagonal_xml("hh:slash", &bf.slash, bf.effective_slash_type()));
    xml.push_str(&build_diagonal_xml(
        "hh:backSlash",
        &bf.back_slash,
        bf.effective_back_slash_type(),
    ));
    xml.push_str(&build_border_line_xml("hh:leftBorder", &bf.left));
    xml.push_str(&build_border_line_xml("hh:rightBorder", &bf.right));
    xml.push_str(&build_border_line_xml("hh:topBorder", &bf.top));
    xml.push_str(&build_border_line_xml("hh:bottomBorder", &bf.bottom));
    if let Some(diag) = &bf.diagonal {
        xml.push_str(&build_border_line_xml("hh:diagonal", diag));
    }
    if has_fill_brush(bf) {
        xml.push_str(&build_fill_brush_xml(bf));
    }
    xml.push_str("</hh:borderFill>");
    xml
}

/// Serializes a diagonal border element (`<hh:slash>` / `<hh:backSlash>`).
fn build_diagonal_xml(tag: &str, diagonal: &HwpxDiagonalLine, legacy_border_type: &str) -> String {
    let border_type = if diagonal.border_type == "NONE" && legacy_border_type != "NONE" {
        legacy_border_type
    } else {
        diagonal.border_type.as_str()
    };
    format!(
        r##"<{tag} type="{border_type}" Crooked="{crooked}" isCounter="{is_counter}"/>"##,
        border_type = border_type,
        crooked = u32::from(diagonal.crooked),
        is_counter = u32::from(diagonal.is_counter),
    )
}

/// Serializes a border line element.
fn build_border_line_xml(tag: &str, line: &HwpxBorderLine) -> String {
    format!(r##"<{tag} type="{}" width="{}" color="{}"/>"##, line.line_type, line.width, line.color,)
}

/// Serializes a fill brush element.
fn build_fill_brush_xml(border_fill: &HwpxBorderFill) -> String {
    match border_fill.active_fill_brush() {
        ActiveBorderFillBrush::Image(fill) => build_image_fill_brush_xml(fill),
        ActiveBorderFillBrush::Gradient(fill) => build_gradient_fill_brush_xml(fill),
        ActiveBorderFillBrush::WinBrush { face_color, hatch_color, hatch_style, alpha } => {
            let hatch_style_attr =
                hatch_style.map(|style| format!(r#" hatchStyle="{style}""#)).unwrap_or_default();
            format!(
                r##"<hc:fillBrush><hc:winBrush faceColor="{face_color}" hatchColor="{hatch_color}"{hatch_style_attr} alpha="{alpha}"/></hc:fillBrush>"##
            )
        }
        ActiveBorderFillBrush::None => {
            unreachable!("fill brush must exist when serializing border fill")
        }
    }
}

fn has_fill_brush(border_fill: &HwpxBorderFill) -> bool {
    !matches!(border_fill.active_fill_brush(), ActiveBorderFillBrush::None)
}

fn build_gradient_fill_brush_xml(fill: &HwpxGradientFill) -> String {
    let colors = fill
        .colors
        .iter()
        .map(|color| format!(r##"<hc:color value="{}"/>"##, color.to_hex_rgb()))
        .collect::<String>();
    format!(
        r##"<hc:fillBrush><hc:gradation type="{gradient_type}" angle="{angle}" centerX="{center_x}" centerY="{center_y}" step="{step}" colorNum="{color_num}" stepCenter="{step_center}" alpha="{alpha}">{colors}</hc:gradation></hc:fillBrush>"##,
        gradient_type = fill.gradient_type,
        angle = fill.angle,
        center_x = fill.center_x,
        center_y = fill.center_y,
        step = fill.step,
        color_num = fill.colors.len(),
        step_center = fill.step_center,
        alpha = fill.alpha,
        colors = colors,
    )
}

fn build_image_fill_brush_xml(fill: &HwpxImageFill) -> String {
    format!(
        r##"<hc:fillBrush><hc:imgBrush mode="{mode}"><hc:img binaryItemIDRef="{binary_item_id_ref}" bright="{bright}" contrast="{contrast}" effect="{effect}" alpha="{alpha}"/></hc:imgBrush></hc:fillBrush>"##,
        mode = fill.mode,
        binary_item_id_ref = fill.binary_item_id_ref,
        bright = fill.bright,
        contrast = fill.contrast,
        effect = fill.effect,
        alpha = fill.alpha,
    )
}

/// Builds `<hh:numberings>` XML from the store's numbering definitions.
///
/// If no numberings exist in the store, emits the default 10-level outline
/// numbering (한글 Modern). Uses string injection (not serde) because
/// `<hh:paraHead>` has mixed XML content (attributes + text body).
///
/// `charPrIDRef="4294967295"` (u32::MAX) means "no override / use default".
fn build_numberings_xml(store: &HwpxStyleStore) -> String {
    let numberings: Vec<NumberingDef> = if store.numbering_count() == 0 {
        vec![NumberingDef::default_outline()]
    } else {
        store.iter_numberings().cloned().collect()
    };

    let count = numberings.len();
    let mut xml = format!(r#"<hh:numberings itemCnt="{count}">"#);
    for ndef in &numberings {
        xml.push_str(&format!(r#"<hh:numbering id="{}" start="{}">"#, ndef.id, ndef.start));
        for lvl in &ndef.levels {
            let num_format = number_format_to_hwpx(lvl.num_format);
            let checkable = u32::from(lvl.checkable);
            if lvl.text.is_empty() {
                // Self-closing for levels with empty text (levels 9 and 10)
                xml.push_str(&format!(
                    r#"<hh:paraHead start="{}" level="{}" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="{num_format}" charPrIDRef="4294967295" checkable="{checkable}"/>"#,
                    lvl.start, lvl.level,
                ));
            } else {
                xml.push_str(&format!(
                    r#"<hh:paraHead start="{}" level="{}" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="{num_format}" charPrIDRef="4294967295" checkable="{checkable}">{}</hh:paraHead>"#,
                    lvl.start,
                    lvl.level,
                    escape_xml(&lvl.text),
                ));
            }
        }
        xml.push_str("</hh:numbering>");
    }
    xml.push_str("</hh:numberings>");
    xml
}

/// Builds `<hh:bullets>` XML from the store's bullet definitions.
fn build_bullets_xml(store: &HwpxStyleStore) -> String {
    if store.bullet_count() == 0 {
        return String::new();
    }

    let items: Vec<HxBullet> = store.iter_bullets().map(bullet_def_to_hwpx).collect();
    let count = items.len() as u32;
    let mut xml = format!(r#"<hh:bullets itemCnt="{count}">"#);
    for bullet in &items {
        xml.push_str(&build_bullet_xml(bullet));
    }
    xml.push_str("</hh:bullets>");
    xml
}

/// Serializes a single bullet definition to XML.
fn build_bullet_xml(bullet: &HxBullet) -> String {
    let checked_char_attr = bullet
        .checked_char
        .as_ref()
        .map(|checked_char| format!(r#" checkedChar="{}""#, escape_xml(checked_char)))
        .unwrap_or_default();
    let mut xml = format!(
        r#"<hh:bullet id="{}" char="{}"{} useImage="{use_image}">"#,
        bullet.id,
        escape_xml(&bullet.bullet_char),
        checked_char_attr,
        use_image = bullet.use_image,
    );
    for para_head in &bullet.para_heads {
        xml.push_str(&build_bullet_para_head_xml(para_head));
    }
    xml.push_str("</hh:bullet>");
    xml
}

/// Serializes a bullet paraHead to XML.
fn build_bullet_para_head_xml(para_head: &HxBulletParaHead) -> String {
    if para_head.text.is_empty() {
        return format!(
            r#"<hh:paraHead level="{}" align="{}" useInstWidth="{}" autoIndent="{}" widthAdjust="{}" textOffsetType="{}" textOffset="{}" numFormat="{}" charPrIDRef="{}" checkable="{}"/>"#,
            para_head.level,
            para_head.align,
            para_head.use_inst_width,
            para_head.auto_indent,
            para_head.width_adjust,
            para_head.text_offset_type,
            para_head.text_offset,
            para_head.num_format,
            para_head.char_pr_id_ref,
            para_head.checkable,
        );
    }

    let mut xml = format!(
        r#"<hh:paraHead level="{}" align="{}" useInstWidth="{}" autoIndent="{}" widthAdjust="{}" textOffsetType="{}" textOffset="{}" numFormat="{}" charPrIDRef="{}" checkable="{}">"#,
        para_head.level,
        para_head.align,
        para_head.use_inst_width,
        para_head.auto_indent,
        para_head.width_adjust,
        para_head.text_offset_type,
        para_head.text_offset,
        para_head.num_format,
        para_head.char_pr_id_ref,
        para_head.checkable,
    );
    xml.push_str(&escape_xml(&para_head.text));
    xml.push_str("</hh:paraHead>");
    xml
}

/// Maps a [`NumberFormatType`] to its HWPX string representation.
fn number_format_to_hwpx(nf: NumberFormatType) -> &'static str {
    match nf {
        NumberFormatType::Digit => "DIGIT",
        NumberFormatType::CircledDigit => "CIRCLED_DIGIT",
        NumberFormatType::RomanCapital => "ROMAN_CAPITAL",
        NumberFormatType::RomanSmall => "ROMAN_SMALL",
        NumberFormatType::LatinCapital => "LATIN_CAPITAL",
        NumberFormatType::LatinSmall => "LATIN_SMALL",
        NumberFormatType::CircledLatinSmall => "CIRCLED_LATIN_SMALL",
        NumberFormatType::HangulSyllable => "HANGUL_SYLLABLE",
        NumberFormatType::HangulJamo => "HANGUL_JAMO",
        NumberFormatType::HanjaDigit => "HANJA_DIGIT",
        NumberFormatType::CircledHangulSyllable => "CIRCLED_HANGUL_SYLLABLE",
        _ => "DIGIT",
    }
}

/// Injects `<hh:borderFills>`, `<hh:tabProperties>`, and `<hh:numberings>`
/// into the serialized refList XML at the correct positions.
///
/// Element order inside `<hh:refList>`:
/// fontfaces → **borderFills** → charProperties → **tabProperties** → **numberings** → **bullets** → paraProperties → styles
fn enrich_ref_list(inner_xml: &str, store: &HwpxStyleStore) -> HwpxResult<String> {
    let border_fills_xml = build_border_fills_xml(store);
    let tab_properties_xml = build_tab_properties_xml(store)?;
    let numberings_xml = build_numberings_xml(store);
    let bullets_xml = build_bullets_xml(store);

    // If no refList exists, nothing to enrich
    if !inner_xml.contains("<hh:refList>") {
        return Ok(format!(
            "<hh:refList>{border_fills_xml}{tab_properties_xml}{numberings_xml}{bullets_xml}</hh:refList>{inner_xml}"
        ));
    }

    let extra_len = border_fills_xml.len()
        + tab_properties_xml.len()
        + numberings_xml.len()
        + bullets_xml.len();
    let mut result = String::with_capacity(inner_xml.len() + extra_len);
    let ref_open = "<hh:refList>";
    let ref_open_pos =
        inner_xml.find(ref_open).expect("refList was confirmed present by contains() check above");
    let after_ref_open = ref_open_pos + ref_open.len();

    // Copy up to and including <hh:refList>
    result.push_str(&inner_xml[..after_ref_open]);

    let rest = &inner_xml[after_ref_open..];

    // Insert borderFills before <hh:charProperties>
    if let Some(cp_pos) = rest.find("<hh:charProperties") {
        result.push_str(&rest[..cp_pos]);
        result.push_str(&border_fills_xml);

        let rest2 = &rest[cp_pos..];
        // Insert tabProperties + numberings + bullets before paraProperties or styles.
        if let Some(insert_pos) =
            rest2.find("<hh:paraProperties").or_else(|| rest2.find("<hh:styles"))
        {
            result.push_str(&rest2[..insert_pos]);
            result.push_str(&tab_properties_xml);
            result.push_str(&numberings_xml);
            result.push_str(&bullets_xml);
            result.push_str(&rest2[insert_pos..]);
        } else {
            result.push_str(rest2);
            result.push_str(&tab_properties_xml);
            result.push_str(&numberings_xml);
            result.push_str(&bullets_xml);
        }
    } else {
        // No charProperties — insert all defaults after fontfaces
        result.push_str(&border_fills_xml);
        result.push_str(&tab_properties_xml);
        result.push_str(&numberings_xml);
        result.push_str(&bullets_xml);
        result.push_str(rest);
    }

    Ok(result)
}

/// Builds a complete `HxHead` from the store data.
fn build_head(store: &HwpxStyleStore, sec_cnt: u32) -> HxHead {
    let ref_list = build_ref_list(store);
    let has_content = ref_list.fontfaces.is_some()
        || ref_list.char_properties.is_some()
        || ref_list.para_properties.is_some()
        || ref_list.styles.is_some();

    HxHead {
        version: "1.4".into(),
        sec_cnt,
        begin_num: None, // beginNum is injected as raw XML in wrap_header_xml
        ref_list: if has_content { Some(ref_list) } else { None },
    }
}

/// Extracts the inner content of the serialized `<head ...>...</head>`.
///
/// Finds the end of the opening `<head ...>` tag and the start of the
/// closing `</head>` tag, returning everything in between.
fn extract_inner_content(xml: &str) -> &str {
    // Find the end of the opening tag: first `>` after `<head`
    let open_end = xml.find('>').map(|i| i + 1).unwrap_or(0);
    // Find the closing tag
    let close_start = xml.rfind("</head>").unwrap_or(xml.len());
    &xml[open_end..close_start]
}

// ── RefList builder ─────────────────────────────────────────────

/// Builds the `HxRefList` from all store data.
fn build_ref_list(store: &HwpxStyleStore) -> HxRefList {
    let fontfaces = build_fontfaces(store);
    let char_properties = build_char_properties(store);
    let para_properties = build_para_properties(store);
    let styles = build_styles(store);
    HxRefList {
        fontfaces: if fontfaces.groups.is_empty() { None } else { Some(fontfaces) },
        border_fills: None,
        char_properties: if char_properties.items.is_empty() {
            None
        } else {
            Some(char_properties)
        },
        // tabProperties, numberings, and bullets are injected via enrich_ref_list
        // (string manipulation) to ensure correct element ordering within <hh:refList>.
        // Serde emits None here.
        tab_properties: None,
        numberings: None,
        bullets: None,
        para_properties: if para_properties.items.is_empty() {
            None
        } else {
            Some(para_properties)
        },
        styles: if styles.items.is_empty() { None } else { Some(styles) },
    }
}

// ── Font builders ───────────────────────────────────────────────

/// Groups fonts by language and builds `HxFontFaces`.
///
/// The decoder flattens all fonts into a single list ordered by store
/// index. The encoder re-groups them by language, preserving insertion
/// order via a simple scan (no external dependency needed).
fn build_fontfaces(store: &HwpxStyleStore) -> HxFontFaces {
    let mut groups = group_fonts_by_lang(store);

    // 한글 requires all 7 language groups to be present.
    // If any are missing, fill them with the first font as fallback.
    const REQUIRED_LANGS: &[&str] =
        &["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];

    if !groups.is_empty() {
        let fallback_group = groups[0].clone();
        for &lang in REQUIRED_LANGS {
            if !groups.iter().any(|g| g.lang == lang) {
                let mut cloned = fallback_group.clone();
                cloned.lang = lang.to_string();
                groups.push(cloned);
            }
        }
        // Sort to canonical order: HANGUL → LATIN → HANJA → JAPANESE → OTHER → SYMBOL → USER
        groups.sort_by_key(|g| {
            REQUIRED_LANGS.iter().position(|&l| l == g.lang).unwrap_or(usize::MAX)
        });
    }

    let item_cnt = groups.len() as u32;
    HxFontFaces { item_cnt, groups }
}

// NOTE: Re-groups the flat font list by language tag for HWPX output.
// This reverses the decoder's flattening. The round-trip is correct
// because 한글 mirrors identical fonts across all language groups.
// See decoder/header.rs convert_char_pr() for the full ASSUMPTION note.
// TODO(v2.0): With per-group font model, this re-grouping becomes unnecessary.
/// Re-groups the store's flat font list by language tag.
///
/// Uses a `Vec`-based ordered map to keep deterministic output without
/// adding `indexmap` as a dependency. Languages appear in the order
/// their first font was encountered.
fn group_fonts_by_lang(store: &HwpxStyleStore) -> Vec<HxFontFaceGroup> {
    // Collect (lang, fonts) pairs preserving first-seen order.
    let mut langs: Vec<String> = Vec::new();
    let mut groups: Vec<Vec<&HwpxFont>> = Vec::new();

    for font in store.iter_fonts() {
        if let Some(pos) = langs.iter().position(|l| l == &font.lang) {
            groups[pos].push(font);
        } else {
            langs.push(font.lang.clone());
            groups.push(vec![font]);
        }
    }

    langs
        .into_iter()
        .zip(groups)
        .map(|(lang, fonts)| {
            let font_cnt = fonts.len() as u32;
            let hx_fonts: Vec<HxFont> = fonts
                .into_iter()
                .map(|f| HxFont {
                    id: f.id,
                    face: f.face_name.clone(),
                    font_type: "TTF".into(),
                    is_embedded: 0,
                    type_info: Some(default_type_info()),
                })
                .collect();
            HxFontFaceGroup { lang, font_cnt, fonts: hx_fonts }
        })
        .collect()
}

// ── CharPr builder ──────────────────────────────────────────────

/// Builds the `HxCharProperties` list from all char shapes in the store.
fn build_char_properties(store: &HwpxStyleStore) -> HxCharProperties {
    let items: Vec<HxCharPr> = store
        .iter_char_shapes()
        .enumerate()
        .map(|(idx, cs)| build_char_pr(idx as u32, cs))
        .collect();
    let item_cnt = items.len() as u32;
    HxCharProperties { item_cnt, items }
}

/// Converts a single `HwpxCharShape` back to `HxCharPr`.
///
/// This is the reverse of `decoder::header::convert_char_pr`.
///
/// Emits all required child elements including `ratio` (100), `spacing` (0),
/// `relSz` (100), and `offset` (0) for all 7 language groups, which 한글
/// expects to be present in every `<hh:charPr>`.
fn build_char_pr(id: u32, cs: &HwpxCharShape) -> HxCharPr {
    let fr = &cs.font_ref;
    HxCharPr {
        id,
        height: cs.height.as_i32().max(0) as u32,
        text_color: cs.text_color.to_hex_rgb(),
        shade_color: shade_color_to_str(cs.shade_color.as_ref()),
        use_font_space: u32::from(cs.use_font_space),
        use_kerning: u32::from(cs.use_kerning),
        sym_mark: emphasis_type_to_hwpx(cs.emphasis).into(),
        border_fill_id_ref: cs.border_fill_id.unwrap_or(2),

        font_ref: Some(HxFontRef {
            hangul: fr.hangul.get() as u32,
            latin: fr.latin.get() as u32,
            hanja: fr.hanja.get() as u32,
            japanese: fr.japanese.get() as u32,
            other: fr.other.get() as u32,
            symbol: fr.symbol.get() as u32,
            user: fr.user.get() as u32,
        }),
        ratio: Some(lang_values_all(cs.ratio)),
        spacing: Some(lang_values_all(cs.spacing)),
        rel_sz: Some(lang_values_all(cs.rel_sz)),
        offset: Some(lang_values_all(cs.char_offset)),
        bold: if cs.bold { Some(HxPresence) } else { None },
        italic: if cs.italic { Some(HxPresence) } else { None },
        underline: Some(HxUnderline {
            underline_type: underline_type_to_hwpx(cs.underline_type).into(),
            shape: "SOLID".into(),
            color: cs.underline_color.as_ref().map_or_else(|| "#000000".into(), |c| c.to_hex_rgb()),
        }),
        strikeout: Some(HxStrikeout {
            shape: strikeout_shape_to_hwpx(cs.strikeout_shape).into(),
            color: cs.strikeout_color.as_ref().map_or_else(|| "#000000".into(), |c| c.to_hex_rgb()),
        }),
        outline: Some(HxOutline { outline_type: outline_type_to_hwpx(cs.outline_type).into() }),
        shadow: Some(HxShadow {
            shadow_type: shadow_type_to_hwpx(cs.shadow_type).into(),
            color: "#B2B2B2".into(),
            offset_x: 10,
            offset_y: 10,
        }),
    }
}

/// Creates an `HxLangValues` with the same value for all 7 language fields.
fn lang_values_all(v: i32) -> HxLangValues {
    HxLangValues { hangul: v, latin: v, hanja: v, japanese: v, other: v, symbol: v, user: v }
}

/// Converts an [`EmphasisType`] to the HWPX `symMark` attribute string.
fn emphasis_type_to_hwpx(e: EmphasisType) -> &'static str {
    match e {
        EmphasisType::None => "NONE",
        EmphasisType::DotAbove => "DOT_ABOVE",
        EmphasisType::RingAbove => "RING_ABOVE",
        EmphasisType::Tilde => "TILDE",
        EmphasisType::Caron => "CARON",
        EmphasisType::Side => "SIDE",
        EmphasisType::Colon => "COLON",
        EmphasisType::GraveAccent => "GRAVE_ACCENT",
        EmphasisType::AcuteAccent => "ACUTE_ACCENT",
        EmphasisType::Circumflex => "CIRCUMFLEX",
        EmphasisType::Macron => "MACRON",
        EmphasisType::HookAbove => "HOOK_ABOVE",
        EmphasisType::DotBelow => "DOT_BELOW",
        _ => "NONE",
    }
}

/// Returns a default `HxTypeInfo` with standard PANOSE-like values.
///
/// Uses `FCAT_GOTHIC` as the family type, which is the most common
/// classification for Korean fonts (both gothic and myeongjo faces
/// use this as a safe default).
fn default_type_info() -> HxTypeInfo {
    HxTypeInfo {
        family_type: "FCAT_GOTHIC".into(),
        weight: 6,
        proportion: 0,
        contrast: 0,
        stroke_variation: 1,
        arm_style: 1,
        letterform: 1,
        midline: 1,
        x_height: 1,
    }
}

// ── ParaPr builder ──────────────────────────────────────────────

/// Builds the `HxParaProperties` list from all para shapes in the store.
fn build_para_properties(store: &HwpxStyleStore) -> HxParaProperties {
    let items: Vec<HxParaPr> = store
        .iter_para_shapes()
        .enumerate()
        .map(|(idx, ps)| build_para_pr(idx as u32, ps))
        .collect();
    let item_cnt = items.len() as u32;
    HxParaProperties { item_cnt, items }
}

/// Converts a single `HwpxParaShape` back to `HxParaPr`.
///
/// This is the reverse of `decoder::header::convert_para_pr`.
///
/// Emits all child elements expected by 한글: heading (NONE default),
/// breakSetting, autoSpacing, margin/lineSpacing (inside hp:switch),
/// and border (referencing borderFill id=2).
fn build_para_pr(id: u32, ps: &HwpxParaShape) -> HxParaPr {
    HxParaPr {
        id,
        tab_pr_id_ref: ps.tab_pr_id_ref,
        condense: ps.condense,
        font_line_height: 0,
        snap_to_grid: 1,
        suppress_line_numbers: 0,
        checked: u32::from(ps.checked),
        align: Some(HxAlign {
            horizontal: alignment_to_str(ps.alignment).into(),
            vertical: "BASELINE".into(),
        }),
        heading: Some(wire_parts_to_heading(ps.heading_type, ps.heading_id_ref, ps.heading_level)),
        break_setting: Some(HxBreakSetting {
            break_latin_word: ps.break_latin_word.to_string(),
            break_non_latin_word: ps.break_non_latin_word.to_string(),
            widow_orphan: 0,
            keep_with_next: 0,
            keep_lines: 0,
            page_break_before: 0,
            line_wrap: "BREAK".into(),
        }),
        auto_spacing: Some(HxAutoSpacing { e_asian_eng: 0, e_asian_num: 0 }),
        switches: vec![build_margin_switch(ps)],
        border: Some(HxBorder {
            border_fill_id_ref: 2,
            offset_left: 0,
            offset_right: 0,
            offset_top: 0,
            offset_bottom: 0,
            connect: 0,
            ignore_margin: 0,
        }),
    }
}

/// Builds the `<hp:switch>` block with both `<hp:case>` and `<hp:default>`.
///
/// Both branches carry identical margin and line-spacing values, which is
/// the standard pattern emitted by the 한글 word processor.
fn build_margin_switch(ps: &HwpxParaShape) -> HxSwitch {
    HxSwitch {
        case: Some(HxSwitchCase {
            required_namespace: "http://www.hancom.co.kr/hwpml/2016/HwpUnitChar".into(),
            margin: Some(build_margin(ps)),
            line_spacing: Some(build_line_spacing(ps)),
        }),
        default: Some(HxSwitchDefault {
            margin: Some(build_margin(ps)),
            line_spacing: Some(build_line_spacing(ps)),
        }),
    }
}

/// Builds an `HxMargin` from a para shape's margin fields.
fn build_margin(ps: &HwpxParaShape) -> HxMargin {
    HxMargin {
        indent: Some(hwpunit_value(ps.indent)),
        left: Some(hwpunit_value(ps.margin_left)),
        right: Some(hwpunit_value(ps.margin_right)),
        prev: Some(hwpunit_value(ps.spacing_before)),
        next: Some(hwpunit_value(ps.spacing_after)),
    }
}

/// Builds an `HxLineSpacing` from a para shape's line-spacing fields.
fn build_line_spacing(ps: &HwpxParaShape) -> HxLineSpacing {
    HxLineSpacing {
        spacing_type: line_spacing_type_to_hwpx(ps.line_spacing_type).into(),
        value: ps.line_spacing.max(0) as u32,
        unit: "HWPUNIT".into(),
    }
}

/// Creates an `HxUnitValue` from an `HwpUnit`.
fn hwpunit_value(u: HwpUnit) -> HxUnitValue {
    HxUnitValue { value: u.as_i32(), unit: "HWPUNIT".into() }
}

// ── Color / alignment helpers ───────────────────────────────────

/// Converts a shade color to its HWPX string representation.
///
/// `None` or black shading is represented as `"none"` in HWPX (meaning no shading),
/// while any other color uses the standard `"#RRGGBB"` format.
fn shade_color_to_str(c: Option<&Color>) -> String {
    match c {
        None => "none".to_string(),
        Some(color) if *color == Color::BLACK => "none".to_string(),
        Some(color) => color.to_hex_rgb(),
    }
}

/// Converts an [`Alignment`] to the HWPX uppercase string.
fn alignment_to_str(a: Alignment) -> &'static str {
    match a {
        Alignment::Left => "LEFT",
        Alignment::Center => "CENTER",
        Alignment::Right => "RIGHT",
        Alignment::Justify => "JUSTIFY",
        Alignment::Distribute => "DISTRIBUTE",
        Alignment::DistributeFlush => "DISTRIBUTE_FLUSH",
        // non_exhaustive: default to LEFT for future variants
        _ => "LEFT",
    }
}

// ── Enum → HWPX string conversion helpers ──────────────────────
//
// HWPX XML uses uppercase string identifiers for style enums.
// These functions convert Foundation enums to their HWPX equivalents.
// The Schema layer (`Hx*` types) stays `String`-typed; conversion
// happens here at the encoder boundary.

/// Converts an [`UnderlineType`] to its HWPX string representation.
fn underline_type_to_hwpx(ut: UnderlineType) -> &'static str {
    match ut {
        UnderlineType::None => "NONE",
        UnderlineType::Bottom => "BOTTOM",
        UnderlineType::Center => "CENTER",
        UnderlineType::Top => "TOP",
        _ => "NONE",
    }
}

/// Converts a [`StrikeoutShape`] to its HWPX string representation.
///
/// Note: HWPX uses `"SLASH"` for [`StrikeoutShape::Continuous`].
fn strikeout_shape_to_hwpx(ss: StrikeoutShape) -> &'static str {
    match ss {
        StrikeoutShape::None => "NONE",
        StrikeoutShape::Continuous => "SLASH",
        StrikeoutShape::Dash => "DASH",
        StrikeoutShape::Dot => "DOT",
        StrikeoutShape::DashDot => "DASH_DOT",
        StrikeoutShape::DashDotDot => "DASH_DOT_DOT",
        _ => "NONE",
    }
}

/// Converts a [`LineSpacingType`] to its HWPX string representation.
///
/// Note: HWPX uses `"PERCENT"` for [`LineSpacingType::Percentage`].
fn line_spacing_type_to_hwpx(lst: LineSpacingType) -> &'static str {
    match lst {
        LineSpacingType::Percentage => "PERCENT",
        LineSpacingType::Fixed => "FIXED",
        LineSpacingType::BetweenLines => "BETWEEN_LINES",
        _ => "PERCENT",
    }
}

/// Converts an [`OutlineType`] to its HWPX string representation.
fn outline_type_to_hwpx(ot: OutlineType) -> &'static str {
    match ot {
        OutlineType::None => "NONE",
        OutlineType::Solid => "SOLID",
        _ => "NONE",
    }
}

/// Converts a [`ShadowType`] to its HWPX string representation.
fn shadow_type_to_hwpx(st: ShadowType) -> &'static str {
    match st {
        ShadowType::None => "NONE",
        ShadowType::Drop => "DROP",
        _ => "NONE",
    }
}

// ── Style builders ──────────────────────────────────────────────

/// Builds the `HxStyles` list from all styles in the store.
///
/// If the store has no styles, injects a minimal "바탕글" (Normal) style
/// which 한글 expects as the default paragraph style (id=0).
fn build_styles(store: &HwpxStyleStore) -> HxStyles {
    let mut items: Vec<HxStyle> = store.iter_styles().map(build_style).collect();

    // 한글 requires at least the "바탕글" (Normal) base style
    if items.is_empty() {
        items.push(HxStyle {
            id: 0,
            style_type: "PARA".into(),
            name: "바탕글".into(),
            eng_name: "Normal".into(),
            para_pr_id_ref: 0,
            char_pr_id_ref: 0,
            next_style_id_ref: 0,
            lang_id: 1042,
            lock_form: 0,
        });
    }

    let item_cnt = items.len() as u32;
    HxStyles { item_cnt, items }
}

/// Converts a single `HwpxStyle` back to `HxStyle`.
fn build_style(s: &HwpxStyle) -> HxStyle {
    HxStyle {
        id: s.id,
        style_type: s.style_type.clone(),
        name: s.name.clone(),
        eng_name: s.eng_name.clone(),
        para_pr_id_ref: s.para_pr_id_ref,
        char_pr_id_ref: s.char_pr_id_ref,
        next_style_id_ref: s.next_style_id_ref,
        lang_id: s.lang_id,
        lock_form: s.lock_form,
    }
}

// ── Tests ───────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::style_store::HwpxFill;
    use hwpforge_foundation::{CharShapeIndex, FontIndex, ParaShapeIndex};

    // ── Helper: build a minimal store ───────────────────────────

    /// Creates a store with 1 HANGUL font, 1 char shape, 1 para shape, and 3 default border fills.
    fn minimal_store() -> HwpxStyleStore {
        let mut store = HwpxStyleStore::new();
        store.push_font(HwpxFont {
            id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
        });
        store.push_char_shape(HwpxCharShape {
            height: HwpUnit::new(1000).unwrap(),
            ..Default::default()
        });
        store.push_para_shape(HwpxParaShape::default());
        store.push_border_fill(HwpxBorderFill::default_page_border());
        store.push_border_fill(HwpxBorderFill::default_char_background());
        store.push_border_fill(HwpxBorderFill::default_table_border());
        store
    }

    // ── 1. Minimal store encode ─────────────────────────────────

    #[test]
    fn test_encode_minimal_store() {
        let store = minimal_store();
        let xml = encode_header(&store, 1, None).unwrap();

        assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>"#));
        assert!(xml.contains("<hh:head"));
        assert!(xml.contains("</hh:head>"));
        assert!(xml.contains(r#"version="1.4""#));
        assert!(xml.contains(r#"secCnt="1""#));
        // Font
        assert!(xml.contains("함초롬돋움"));
        assert!(xml.contains(r#"lang="HANGUL""#));
        // Char shape
        assert!(xml.contains(r#"height="1000""#));
        assert!(xml.contains(r##"textColor="#000000""##));
        // Para shape
        assert!(xml.contains(r#"horizontal="LEFT""#));
        assert!(xml.contains(r#"vertical="BASELINE""#));
    }

    // ── 2. Encode-decode roundtrip ──────────────────────────────

    #[test]
    fn test_encode_header_roundtrip() {
        let store = minimal_store();
        let xml = encode_header(&store, 1, None).unwrap();

        // The decoder strips namespace prefixes, so we need to feed it
        // XML without them. However, the decoder's parse_header uses
        // quick-xml::de which strips namespace prefixes automatically.
        let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;

        // Font roundtrip: encoder expands to 7 language groups (1 font × 7 = 7)
        assert_eq!(decoded.font_count(), 7);
        let f = decoded.font(FontIndex::new(0)).unwrap();
        assert_eq!(f.face_name, "함초롬돋움");
        assert_eq!(f.lang, "HANGUL");

        // Char shape roundtrip
        assert_eq!(decoded.char_shape_count(), store.char_shape_count());
        let cs = decoded.char_shape(CharShapeIndex::new(0)).unwrap();
        assert_eq!(cs.height.as_i32(), 1000);
        assert_eq!(cs.text_color, Color::BLACK);
        assert!(!cs.bold);
        assert!(!cs.italic);

        // Para shape roundtrip
        assert_eq!(decoded.para_shape_count(), store.para_shape_count());
        let ps = decoded.para_shape(ParaShapeIndex::new(0)).unwrap();
        assert_eq!(ps.alignment, Alignment::Left);
        assert_eq!(ps.line_spacing, 160);
        assert_eq!(ps.line_spacing_type, LineSpacingType::Percentage);
    }

    #[test]
    fn test_with_default_fonts_emit_group_local_zero_ids() {
        let mut store = HwpxStyleStore::with_default_fonts("함초롬돋움");
        store.push_char_shape(HwpxCharShape::default());
        store.push_para_shape(HwpxParaShape::default());
        store.push_border_fill(HwpxBorderFill::default_page_border());
        store.push_border_fill(HwpxBorderFill::default_char_background());
        store.push_border_fill(HwpxBorderFill::default_table_border());

        let xml = encode_header(&store, 1, None).unwrap();

        assert!(xml.contains(r#"<hh:fontface lang="HANGUL" fontCnt="1"><hh:font id="0""#));
        assert!(xml.contains(r#"<hh:fontface lang="LATIN" fontCnt="1"><hh:font id="0""#));
        assert!(xml.contains(
            r#"<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>"#
        ));
    }

    // ── 3. Bold/italic presence ─────────────────────────────────

    #[test]
    fn test_bold_italic_presence() {
        let mut store = HwpxStyleStore::new();
        store.push_char_shape(HwpxCharShape { bold: true, italic: false, ..Default::default() });
        let xml = encode_header(&store, 1, None).unwrap();

        assert!(xml.contains("<hh:bold"), "bold element must be present");
        assert!(!xml.contains("<hh:italic"), "italic element must be absent");
    }

    // ── 4. shade_color_to_str ───────────────────────────────────

    #[test]
    fn test_shade_color_none() {
        assert_eq!(shade_color_to_str(None), "none");
        assert_eq!(shade_color_to_str(Some(&Color::BLACK)), "none");
        assert_eq!(shade_color_to_str(Some(&Color::from_rgb(0, 255, 0))), "#00FF00");
        assert_eq!(shade_color_to_str(Some(&Color::WHITE)), "#FFFFFF");
    }

    // ── 6. alignment_to_str ─────────────────────────────────────

    #[test]
    fn test_alignment_to_str() {
        assert_eq!(alignment_to_str(Alignment::Left), "LEFT");
        assert_eq!(alignment_to_str(Alignment::Center), "CENTER");
        assert_eq!(alignment_to_str(Alignment::Right), "RIGHT");
        assert_eq!(alignment_to_str(Alignment::Justify), "JUSTIFY");
        assert_eq!(alignment_to_str(Alignment::Distribute), "DISTRIBUTE");
        assert_eq!(alignment_to_str(Alignment::DistributeFlush), "DISTRIBUTE_FLUSH");
    }

    // ── 7. Font grouping ────────────────────────────────────────

    #[test]
    fn test_font_grouping() {
        let mut store = HwpxStyleStore::new();
        store.push_font(HwpxFont {
            id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
        });
        store.push_font(HwpxFont {
            id: 1, face_name: "함초롬바탕".into(), lang: "HANGUL".into()
        });
        store.push_font(HwpxFont { id: 0, face_name: "Arial".into(), lang: "LATIN".into() });

        let groups = group_fonts_by_lang(&store);
        assert_eq!(groups.len(), 2);

        // First group: HANGUL with 2 fonts
        assert_eq!(groups[0].lang, "HANGUL");
        assert_eq!(groups[0].font_cnt, 2);
        assert_eq!(groups[0].fonts.len(), 2);
        assert_eq!(groups[0].fonts[0].face, "함초롬돋움");
        assert_eq!(groups[0].fonts[1].face, "함초롬바탕");

        // Second group: LATIN with 1 font
        assert_eq!(groups[1].lang, "LATIN");
        assert_eq!(groups[1].font_cnt, 1);
        assert_eq!(groups[1].fonts[0].face, "Arial");
    }

    // ── 8. Margin switch structure ──────────────────────────────

    #[test]
    fn test_margin_switch_structure() {
        let ps = HwpxParaShape {
            alignment: Alignment::Justify,
            margin_left: HwpUnit::new(100).unwrap(),
            margin_right: HwpUnit::new(50).unwrap(),
            indent: HwpUnit::new(200).unwrap(),
            spacing_before: HwpUnit::new(300).unwrap(),
            spacing_after: HwpUnit::new(150).unwrap(),
            line_spacing: 200,
            line_spacing_type: LineSpacingType::Percentage,
            ..Default::default()
        };

        let switch = build_margin_switch(&ps);

        // Case must be present
        let case = switch.case.as_ref().expect("case must be present");
        assert_eq!(case.required_namespace, "http://www.hancom.co.kr/hwpml/2016/HwpUnitChar");
        let case_margin = case.margin.as_ref().unwrap();
        assert_eq!(case_margin.left.as_ref().unwrap().value, 100);
        assert_eq!(case_margin.right.as_ref().unwrap().value, 50);
        assert_eq!(case_margin.indent.as_ref().unwrap().value, 200);
        assert_eq!(case_margin.prev.as_ref().unwrap().value, 300);
        assert_eq!(case_margin.next.as_ref().unwrap().value, 150);
        let case_ls = case.line_spacing.as_ref().unwrap();
        assert_eq!(case_ls.value, 200);
        assert_eq!(case_ls.spacing_type, "PERCENT");

        // Default must be present with identical values
        let default = switch.default.as_ref().expect("default must be present");
        let def_margin = default.margin.as_ref().unwrap();
        assert_eq!(def_margin.left.as_ref().unwrap().value, 100);
        assert_eq!(def_margin.indent.as_ref().unwrap().value, 200);
        let def_ls = default.line_spacing.as_ref().unwrap();
        assert_eq!(def_ls.value, 200);
    }

    #[test]
    fn test_bullet_roundtrip_emits_bullets_section() {
        let mut store = HwpxStyleStore::new();
        store.push_font(HwpxFont::new(0, "함초롬돋움", "HANGUL"));
        store.push_char_shape(HwpxCharShape::default());
        store.push_para_shape(HwpxParaShape::default());
        store.push_border_fill(HwpxBorderFill::default_page_border());
        store.push_border_fill(HwpxBorderFill::default_char_background());
        store.push_border_fill(HwpxBorderFill::default_table_border());
        store.push_bullet(hwpforge_core::BulletDef {
            id: 1,
            bullet_char: "".into(),
            checked_char: None,
            use_image: false,
            para_head: hwpforge_core::ParaHead {
                start: 0,
                level: 1,
                num_format: NumberFormatType::Digit,
                text: String::new(),
                checkable: false,
            },
        });

        let xml = encode_header(&store, 1, None).unwrap();
        assert!(xml.contains(r#"<hh:bullets itemCnt="1">"#));
        assert!(xml.contains(r#"char="""#));
        assert!(xml.contains(r#"useImage="0""#));
        assert!(xml.contains(r#"<hh:paraHead level="0""#));

        let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
        assert_eq!(decoded.bullet_count(), 1);
        let bullet = decoded.iter_bullets().next().unwrap();
        assert_eq!(bullet.id, 1);
        assert_eq!(bullet.bullet_char, "");
        assert!(!bullet.use_image);
        assert_eq!(bullet.para_head.level, 1);
    }

    #[test]
    fn test_list_text_is_xml_escaped_and_roundtrips() {
        let mut store = HwpxStyleStore::new();
        store.push_font(HwpxFont::new(0, "함초롬돋움", "HANGUL"));
        store.push_char_shape(HwpxCharShape::default());
        store.push_para_shape(HwpxParaShape::default());
        store.push_border_fill(HwpxBorderFill::default_page_border());
        store.push_border_fill(HwpxBorderFill::default_char_background());
        store.push_border_fill(HwpxBorderFill::default_table_border());
        store.push_numbering(NumberingDef {
            id: 9,
            start: 0,
            levels: vec![hwpforge_core::ParaHead {
                start: 1,
                level: 1,
                num_format: NumberFormatType::Digit,
                text: r#"A&B<"C""#.into(),
                checkable: false,
            }],
        });
        store.push_bullet(hwpforge_core::BulletDef {
            id: 1,
            bullet_char: r#"<"&"#.into(),
            checked_char: None,
            use_image: false,
            para_head: hwpforge_core::ParaHead {
                start: 0,
                level: 1,
                num_format: NumberFormatType::Digit,
                text: r#"X&Y<"Z""#.into(),
                checkable: false,
            },
        });

        let xml = encode_header(&store, 1, None).unwrap();
        assert!(xml.contains(r#"char="&lt;&quot;&amp;""#));
        assert!(xml.contains(r#"A&amp;B&lt;&quot;C&quot;"#));
        assert!(xml.contains(r#"X&amp;Y&lt;&quot;Z&quot;"#));

        let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
        let numbering = decoded.iter_numberings().find(|numbering| numbering.id == 9).unwrap();
        assert_eq!(numbering.levels[0].text, r#"A&B<"C""#);

        let bullet = decoded.iter_bullets().next().unwrap();
        assert_eq!(bullet.bullet_char, r#"<"&"#);
        assert_eq!(bullet.para_head.text, r#"X&Y<"Z""#);
    }

    // ── 9. Empty store ──────────────────────────────────────────

    #[test]
    fn test_empty_store() {
        let store = HwpxStyleStore::new();
        let xml = encode_header(&store, 0, None).unwrap();

        assert!(xml.contains("<hh:head"));
        assert!(xml.contains(r#"secCnt="0""#));
        // refList should be empty (all fields None → skip_serializing_if)
        // The serialized HxRefList with all None fields should produce
        // an empty element or just the wrapper.
        assert!(xml.contains("</hh:head>"));
    }

    // ── 10. Roundtrip with rich data ────────────────────────────

    #[test]
    fn test_roundtrip_rich_data() {
        let mut store = HwpxStyleStore::new();

        // 2 HANGUL fonts + 1 LATIN font
        store.push_font(HwpxFont {
            id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
        });
        store.push_font(HwpxFont {
            id: 1, face_name: "함초롬바탕".into(), lang: "HANGUL".into()
        });
        store.push_font(HwpxFont {
            id: 0,
            face_name: "Times New Roman".into(),
            lang: "LATIN".into(),
        });

        // Bold + colored char shape
        store.push_char_shape(HwpxCharShape {
            font_ref: crate::style_store::HwpxFontRef {
                hangul: FontIndex::new(1),
                latin: FontIndex::new(2),
                ..Default::default()
            },
            height: HwpUnit::new(2500).unwrap(),
            text_color: Color::from_rgb(255, 0, 0),
            shade_color: Some(Color::from_rgb(0, 255, 0)),
            bold: true,
            italic: true,
            underline_type: UnderlineType::Bottom,
            strikeout_shape: StrikeoutShape::Continuous,
            ..Default::default()
        });

        // Justified para with margins
        store.push_para_shape(HwpxParaShape {
            alignment: Alignment::Justify,
            margin_left: HwpUnit::new(100).unwrap(),
            margin_right: HwpUnit::new(50).unwrap(),
            indent: HwpUnit::new(200).unwrap(),
            spacing_before: HwpUnit::new(300).unwrap(),
            spacing_after: HwpUnit::new(150).unwrap(),
            line_spacing: 200,
            line_spacing_type: LineSpacingType::Percentage,
            ..Default::default()
        });

        let xml = encode_header(&store, 1, None).unwrap();
        let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;

        // Fonts: encoder expands to 7 language groups
        // HANGUL: 2, LATIN: 1, HANJA/JAPANESE/OTHER/SYMBOL/USER: 2 each (cloned from HANGUL)
        assert_eq!(decoded.font_count(), 13);
        assert_eq!(decoded.font(FontIndex::new(0)).unwrap().face_name, "함초롬돋움");
        assert_eq!(decoded.font(FontIndex::new(1)).unwrap().face_name, "함초롬바탕");
        assert_eq!(decoded.font(FontIndex::new(2)).unwrap().face_name, "Times New Roman");

        // Char shape
        let cs = decoded.char_shape(CharShapeIndex::new(0)).unwrap();
        assert_eq!(cs.height.as_i32(), 2500);
        assert_eq!(cs.text_color, Color::from_rgb(255, 0, 0));
        assert_eq!(cs.shade_color, Some(Color::from_rgb(0, 255, 0)));
        assert!(cs.bold);
        assert!(cs.italic);
        assert_eq!(cs.font_ref.hangul.get(), 1);
        assert_eq!(cs.font_ref.latin.get(), 2);
        assert_eq!(cs.underline_type, UnderlineType::Bottom);
        assert_eq!(cs.strikeout_shape, StrikeoutShape::Continuous);

        // Para shape
        let ps = decoded.para_shape(ParaShapeIndex::new(0)).unwrap();
        assert_eq!(ps.alignment, Alignment::Justify);
        assert_eq!(ps.margin_left.as_i32(), 100);
        assert_eq!(ps.margin_right.as_i32(), 50);
        assert_eq!(ps.indent.as_i32(), 200);
        assert_eq!(ps.spacing_before.as_i32(), 300);
        assert_eq!(ps.spacing_after.as_i32(), 150);
        assert_eq!(ps.line_spacing, 200);
    }

    // ── 11. sec_cnt propagation ─────────────────────────────────

    #[test]
    fn test_sec_cnt_in_output() {
        let store = HwpxStyleStore::new();
        let xml = encode_header(&store, 42, None).unwrap();
        assert!(xml.contains(r#"secCnt="42""#));
    }

    // ── 12. Multiple char shapes get sequential IDs ─────────────

    #[test]
    fn test_multiple_char_shapes_ids() {
        let mut store = HwpxStyleStore::new();
        store.push_char_shape(HwpxCharShape { bold: true, ..Default::default() });
        store.push_char_shape(HwpxCharShape { italic: true, ..Default::default() });
        store.push_char_shape(HwpxCharShape::default());

        let xml = encode_header(&store, 1, None).unwrap();
        let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;

        assert_eq!(decoded.char_shape_count(), 3);
        assert!(decoded.char_shape(CharShapeIndex::new(0)).unwrap().bold);
        assert!(decoded.char_shape(CharShapeIndex::new(1)).unwrap().italic);
        assert!(!decoded.char_shape(CharShapeIndex::new(2)).unwrap().bold);
        assert!(!decoded.char_shape(CharShapeIndex::new(2)).unwrap().italic);
    }

    // ── 13. Styles roundtrip ────────────────────────────────────

    #[test]
    fn test_styles_roundtrip() {
        let mut store = HwpxStyleStore::new();
        store.push_style(crate::style_store::HwpxStyle {
            id: 0,
            style_type: "PARA".into(),
            name: "바탕글".into(),
            eng_name: "Normal".into(),
            para_pr_id_ref: 0,
            char_pr_id_ref: 0,
            next_style_id_ref: 0,
            lang_id: 1042,
            lock_form: 0,
        });
        store.push_style(crate::style_store::HwpxStyle {
            id: 1,
            style_type: "CHAR".into(),
            name: "본문".into(),
            eng_name: "Body".into(),
            para_pr_id_ref: 1,
            char_pr_id_ref: 1,
            next_style_id_ref: 1,
            lang_id: 1042,
            lock_form: 0,
        });

        let xml = encode_header(&store, 1, None).unwrap();
        assert!(xml.contains("바탕글"));
        assert!(xml.contains("Normal"));
        assert!(xml.contains("본문"));
        assert!(xml.contains("Body"));

        let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
        assert_eq!(decoded.style_count(), 2);

        let s0 = decoded.style(0).unwrap();
        assert_eq!(s0.name, "바탕글");
        assert_eq!(s0.eng_name, "Normal");
        assert_eq!(s0.style_type, "PARA");
        assert_eq!(s0.lang_id, 1042);

        let s1 = decoded.style(1).unwrap();
        assert_eq!(s1.name, "본문");
        assert_eq!(s1.eng_name, "Body");
        assert_eq!(s1.style_type, "CHAR");
    }

    #[test]
    fn test_empty_store_gets_default_style() {
        let store = HwpxStyleStore::new();
        let xml = encode_header(&store, 1, None).unwrap();
        // 한글 requires at least the "바탕글" default style
        assert!(xml.contains("<hh:styles"), "default 바탕글 style should be injected");
        assert!(xml.contains("바탕글"), "바탕글 style name must be present");
        assert!(xml.contains("Normal"), "Normal eng_name must be present");
    }

    // ── 14. Verify all 6 encoder improvements ──────────────────

    #[test]
    fn test_encoder_improvements_all_present() {
        let store = minimal_store();
        let xml = encode_header(&store, 1, None).unwrap();

        // Gap 1: charPr ratio/spacing/relSz/offset
        assert!(xml.contains("<hh:ratio hangul=\"100\""), "charPr must have ratio");
        assert!(xml.contains("<hh:spacing hangul=\"0\""), "charPr must have spacing");
        assert!(xml.contains("<hh:relSz hangul=\"100\""), "charPr must have relSz");
        assert!(xml.contains("<hh:offset hangul=\"0\""), "charPr must have offset");

        // Gap 2: paraPr heading/breakSetting/autoSpacing/border
        assert!(xml.contains("<hh:heading type=\"NONE\""), "paraPr must have heading");
        assert!(
            xml.contains("<hh:breakSetting breakLatinWord=\"KEEP_WORD\""),
            "paraPr must have breakSetting"
        );
        assert!(xml.contains("<hh:autoSpacing eAsianEng=\"0\""), "paraPr must have autoSpacing");
        assert!(xml.contains("<hh:border borderFillIDRef=\"2\""), "paraPr must have border");

        // Gap 3: borderFill id=2 fillBrush
        assert!(xml.contains("<hc:fillBrush>"), "borderFill id=2 must have fillBrush");
        assert!(xml.contains("<hc:winBrush faceColor=\"none\""), "fillBrush must have winBrush");
        assert!(xml.contains("Crooked=\"0\""), "slash/backSlash must have Crooked attr");
        assert!(xml.contains("isCounter=\"0\""), "slash/backSlash must have isCounter attr");

        // Gap 4: tabProperties 3 entries (id=0 default, id=1 autoTabLeft, id=2 autoTabRight)
        assert!(
            xml.contains("<hh:tabProperties itemCnt=\"3\""),
            "tabProperties must have 3 entries"
        );
        assert!(xml.contains("<hh:tabPr id=\"1\" autoTabLeft=\"1\""), "tabPr id=1 must exist");
        assert!(
            xml.contains("<hh:tabPr id=\"2\" autoTabLeft=\"0\" autoTabRight=\"1\""),
            "tabPr id=2 must exist"
        );

        // Gap 5: numberings
        assert!(xml.contains("<hh:numberings itemCnt=\"1\""), "numberings must exist");
        assert!(
            xml.contains("<hh:paraHead start=\"1\" level=\"1\""),
            "numbering must have paraHead levels with start attr"
        );
        assert!(xml.contains("numFormat=\"DIGIT\""), "paraHead must have numFormat");
        assert!(
            xml.contains("charPrIDRef=\"4294967295\""),
            "paraHead must use u32::MAX for no override"
        );
        assert!(xml.contains("widthAdjust=\"0\""), "paraHead must have widthAdjust attr");
        assert!(xml.contains("checkable=\"0\""), "paraHead must have checkable attr");
    }

    // ── Border fill XML generation ───────────────────────────────

    #[test]
    fn build_border_fills_xml_matches_expected_format() {
        let mut store = HwpxStyleStore::new();
        store.push_border_fill(HwpxBorderFill::default_page_border());
        store.push_border_fill(HwpxBorderFill::default_char_background());
        store.push_border_fill(HwpxBorderFill::default_table_border());
        let xml = build_border_fills_xml(&store);

        // Validate structure
        assert!(xml.starts_with(r##"<hh:borderFills itemCnt="3">"##));
        assert!(xml.ends_with("</hh:borderFills>"));
        // id=1 no fill
        assert!(xml.contains(r##"<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE""##));
        // id=2 has fillBrush
        assert!(xml.contains(r##"<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE""##));
        assert!(xml.contains("<hc:fillBrush>"));
        assert!(xml.contains(r##"faceColor="none""##));
        assert!(xml.contains(r##"hatchColor="#FF000000""##));
        assert!(!xml.contains(r##"hatchStyle=""##));
        // id=3 has SOLID borders
        assert!(xml.contains(r##"<hh:borderFill id="3""##));
        assert!(xml.contains(r##"<hh:leftBorder type="SOLID" width="0.12 mm""##));
        // Diagonal present in all
        assert!(xml.contains(r##"<hh:diagonal type="SOLID" width="0.1 mm""##));
        // Slash/backSlash present in all
        assert!(xml.contains(r##"<hh:slash type="NONE" Crooked="0" isCounter="0"/>"##));
        assert!(xml.contains(r##"<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>"##));
    }

    #[test]
    fn build_border_fills_xml_empty_store_emits_defaults() {
        // An empty store (no fills) still emits 3 standard defaults for backward compat.
        let store = HwpxStyleStore::new();
        let xml = build_border_fills_xml(&store);
        assert!(xml.contains(r##"<hh:borderFills itemCnt="3">"##));
        assert!(xml.contains(r##"<hh:borderFill id="1""##));
        assert!(xml.contains(r##"<hh:borderFill id="2""##));
        assert!(xml.contains(r##"<hh:borderFill id="3""##));
    }

    #[test]
    fn encoded_header_contains_dynamic_border_fills() {
        let store = minimal_store();
        let xml = encode_header(&store, 1, None).unwrap();
        // Dynamic generation produces the same structure as the old constant
        assert!(xml.contains(r##"<hh:borderFills itemCnt="3">"##));
        assert!(xml.contains(r##"borderFillIDRef="2""##)); // charPr references fill id=2
    }

    #[test]
    fn build_border_fill_xml_emits_hatch_style_when_present() {
        let xml = build_border_fill_xml(&HwpxBorderFill {
            id: 4,
            three_d: false,
            shadow: false,
            center_line: "NONE".into(),
            left: HwpxBorderLine::default(),
            right: HwpxBorderLine::default(),
            top: HwpxBorderLine::default(),
            bottom: HwpxBorderLine::default(),
            diagonal: Some(HwpxBorderLine::default()),
            slash_type: "NONE".into(),
            back_slash_type: "NONE".into(),
            slash: HwpxDiagonalLine::default(),
            back_slash: HwpxDiagonalLine::default(),
            fill: Some(HwpxFill::WinBrush {
                face_color: "#FFD700".into(),
                hatch_color: "#000000".into(),
                alpha: "0".into(),
            }),
            fill_hatch_style: Some("HORIZONTAL".into()),
            gradient_fill: None,
            image_fill: None,
        });

        assert!(xml.contains(r##"hatchStyle="HORIZONTAL""##));
    }

    #[test]
    fn build_border_fill_xml_emits_gradient_fill() {
        let xml = build_border_fill_xml(&HwpxBorderFill {
            id: 4,
            three_d: false,
            shadow: false,
            center_line: "NONE".into(),
            left: HwpxBorderLine::default(),
            right: HwpxBorderLine::default(),
            top: HwpxBorderLine::default(),
            bottom: HwpxBorderLine::default(),
            diagonal: Some(HwpxBorderLine::default()),
            slash_type: "NONE".into(),
            back_slash_type: "NONE".into(),
            slash: HwpxDiagonalLine::default(),
            back_slash: HwpxDiagonalLine::default(),
            fill: None,
            fill_hatch_style: None,
            gradient_fill: Some(HwpxGradientFill {
                gradient_type: hwpforge_foundation::GradientType::Linear,
                angle: 90,
                center_x: 0,
                center_y: 0,
                step: 255,
                step_center: 50,
                alpha: 0,
                colors: vec![Color::from_rgb(255, 0, 0), Color::from_rgb(0, 255, 0)],
            }),
            image_fill: None,
        });

        assert!(xml.contains(r##"<hc:gradation type="LINEAR" angle="90" centerX="0" centerY="0" step="255" colorNum="2" stepCenter="50" alpha="0">"##));
        assert!(xml.contains(r##"<hc:color value="#FF0000"/>"##));
        assert!(xml.contains(r##"<hc:color value="#00FF00"/>"##));
    }

    #[test]
    fn build_border_fill_xml_emits_image_fill() {
        let xml = build_border_fill_xml(&HwpxBorderFill {
            id: 4,
            three_d: false,
            shadow: false,
            center_line: "NONE".into(),
            left: HwpxBorderLine::default(),
            right: HwpxBorderLine::default(),
            top: HwpxBorderLine::default(),
            bottom: HwpxBorderLine::default(),
            diagonal: Some(HwpxBorderLine::default()),
            slash_type: "NONE".into(),
            back_slash_type: "NONE".into(),
            slash: HwpxDiagonalLine::default(),
            back_slash: HwpxDiagonalLine::default(),
            fill: None,
            fill_hatch_style: None,
            gradient_fill: None,
            image_fill: Some(HwpxImageFill {
                mode: "TOTAL".into(),
                binary_item_id_ref: "BIN0001".into(),
                bright: 0,
                contrast: 0,
                effect: "REAL_PIC".into(),
                alpha: 0,
            }),
        });

        assert!(xml.contains(r##"<hc:imgBrush mode="TOTAL">"##));
        assert!(xml.contains(r##"<hc:img binaryItemIDRef="BIN0001" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/>"##));
    }

    #[test]
    fn build_border_fill_xml_preserves_diagonal_flags() {
        let xml = build_border_fill_xml(&HwpxBorderFill {
            id: 4,
            three_d: false,
            shadow: false,
            center_line: "NONE".into(),
            left: HwpxBorderLine::default(),
            right: HwpxBorderLine::default(),
            top: HwpxBorderLine::default(),
            bottom: HwpxBorderLine::default(),
            diagonal: Some(HwpxBorderLine::default()),
            slash_type: "CENTER_BELOW".into(),
            back_slash_type: "ALL".into(),
            slash: HwpxDiagonalLine {
                border_type: "CENTER_BELOW".into(),
                crooked: true,
                is_counter: false,
            },
            back_slash: HwpxDiagonalLine {
                border_type: "ALL".into(),
                crooked: false,
                is_counter: true,
            },
            fill: None,
            fill_hatch_style: None,
            gradient_fill: None,
            image_fill: None,
        });

        assert!(xml.contains(r##"<hh:slash type="CENTER_BELOW" Crooked="1" isCounter="0"/>"##));
        assert!(xml.contains(r##"<hh:backSlash type="ALL" Crooked="0" isCounter="1"/>"##));
    }

    #[test]
    fn build_border_fill_xml_falls_back_to_legacy_diagonal_type_fields() {
        let xml = build_border_fill_xml(&HwpxBorderFill {
            id: 4,
            three_d: false,
            shadow: false,
            center_line: "NONE".into(),
            left: HwpxBorderLine::default(),
            right: HwpxBorderLine::default(),
            top: HwpxBorderLine::default(),
            bottom: HwpxBorderLine::default(),
            diagonal: Some(HwpxBorderLine::default()),
            slash_type: "CENTER".into(),
            back_slash_type: "ALL".into(),
            slash: HwpxDiagonalLine::default(),
            back_slash: HwpxDiagonalLine::default(),
            fill: None,
            fill_hatch_style: None,
            gradient_fill: None,
            image_fill: None,
        });

        assert!(xml.contains(r##"<hh:slash type="CENTER" Crooked="0" isCounter="0"/>"##));
        assert!(xml.contains(r##"<hh:backSlash type="ALL" Crooked="0" isCounter="0"/>"##));
    }
}
