// --- 40K rulebook Classification§Designation conventions. -------------------
// These pin the strings the adapter looks for. Tune them in one place if a
// real Rosterizer export uses different labels.
const CLS_ROSTER = "Roster";
const CLS_FACTION = "Faction";
const CLS_DETACHMENT = "Detachment";
const CLS_UNIT = "Unit";
const CLS_SQUAD = "Squad"; // alternative unit class some rulebooks use
const CLS_WEAPON = "Weapon";
const CLS_ENHANCEMENT = "Enhancement";
const CLS_BATTLE_SIZE = "Battle Size";
const CLS_TRAIT = "Trait";
const DSG_WARLORD = "Warlord";
const CHAR_CLASSIFICATIONS = new Set(["Character", "Epic Hero"]);
const POINTS_STAT_KEYS = ["Points", "Pts"];
const POINTS_LIMIT = /(\d[\d,]*)\s*Point/i;
function asArray(value) {
    return Array.isArray(value) ? value : [];
}
function asObject(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value)
        ? value
        : null;
}
function asString(value) {
    return typeof value === "string" ? value : null;
}
function asNumber(value) {
    if (typeof value === "number" && Number.isFinite(value))
        return value;
    if (typeof value === "string") {
        const n = Number.parseFloat(value);
        return Number.isFinite(n) ? n : null;
    }
    return null;
}
/** Split `Classification§Designation` into its two halves. Falls back to the
 * raw `classification`/`designation` fields when `item` is absent. */
function splitItem(asset) {
    const item = asString(asset.item);
    if (item !== null) {
        const idx = item.indexOf("§"); // §
        if (idx >= 0) {
            return {
                classification: item.slice(0, idx),
                designation: item.slice(idx + 1),
            };
        }
    }
    return {
        classification: asString(asset.classification) ?? "",
        designation: asString(asset.designation) ?? "",
    };
}
/** A user-facing display name for an asset: `name` override beats the
 * designation parsed out of the `item` key. */
function displayName(asset) {
    return asString(asset.name) ?? splitItem(asset).designation;
}
function quantity(asset) {
    const n = asNumber(asset.quantity);
    return n !== null && n > 0 ? Math.trunc(n) : 1;
}
function included(asset) {
    const a = asset.assets;
    return asArray(a?.included);
}
function traits(asset) {
    const a = asset.assets;
    return asArray(a?.traits);
}
/** Points cost from `stats.Points.value` (or aliases) / `meta.points`, or null. */
function pointsOf(asset) {
    const stats = asObject(asset.stats);
    if (stats) {
        for (const key of POINTS_STAT_KEYS) {
            const stat = asObject(stats[key]);
            if (stat) {
                const v = asNumber(stat.value);
                if (v !== null)
                    return Math.trunc(v);
            }
        }
    }
    const meta = asObject(asset.meta);
    if (meta) {
        const v = asNumber(meta.points);
        if (v !== null)
            return Math.trunc(v);
    }
    return null;
}
/** Depth-first visit of an asset and every included/trait descendant. */
function walk(asset, visit) {
    visit(asset);
    for (const child of included(asset))
        walk(child, visit);
    for (const child of traits(asset))
        walk(child, visit);
}
function classOf(asset) {
    return splitItem(asset).classification;
}
function isUnitAsset(asset) {
    const cls = classOf(asset);
    return cls === CLS_UNIT || cls === CLS_SQUAD;
}
function isWeaponAsset(asset) {
    const cls = classOf(asset);
    // Match exact "Weapon", or any "<X> Weapon" classification (e.g. "Ranged Weapon").
    return cls === CLS_WEAPON || cls.endsWith(` ${CLS_WEAPON}`);
}
function isEnhancementAsset(asset) {
    return classOf(asset) === CLS_ENHANCEMENT;
}
function isCharacterAsset(asset) {
    const keywords = asObject(asset.keywords);
    if (keywords) {
        for (const list of Object.values(keywords)) {
            for (const kw of asArray(list)) {
                if (typeof kw === "string" && CHAR_CLASSIFICATIONS.has(kw))
                    return true;
            }
        }
    }
    // Any nested trait classified as Character also flags the unit.
    for (const t of traits(asset)) {
        if (CHAR_CLASSIFICATIONS.has(classOf(t)))
            return true;
        const dsg = displayName(t);
        if (CHAR_CLASSIFICATIONS.has(dsg))
            return true;
    }
    return false;
}
function isWarlordTrait(asset) {
    const { classification, designation } = splitItem(asset);
    if (designation === DSG_WARLORD)
        return true;
    return classification === CLS_TRAIT && designation === DSG_WARLORD;
}
/** Sum every Weapon/Enhancement-bearing leaf quantity to derive the unit's
 * model count. Falls back to `quantity(unit)` when the tree has no per-model
 * markers (single-model entries). */
function modelCount(unit) {
    // Rosterizer doesn't carve "model" out separately from "unit" the way
    // BattleScribe does; the unit's own quantity is the model count for
    // squads. For multi-model squads with explicit per-model children, each
    // child unit-class asset's quantity contributes.
    let nested = 0;
    for (const child of included(unit)) {
        if (isUnitAsset(child))
            nested += quantity(child);
    }
    return nested > 0 ? nested : quantity(unit);
}
function parseUnit(unit) {
    const wargear = [];
    let enhancement_raw_name = null;
    let enhancement_points = null;
    let is_warlord = false;
    for (const child of included(unit)) {
        walk(child, (a) => {
            if (isEnhancementAsset(a)) {
                if (enhancement_raw_name === null) {
                    enhancement_raw_name = displayName(a);
                    enhancement_points = pointsOf(a);
                }
                return;
            }
            if (isWeaponAsset(a)) {
                wargear.push({ raw_name: displayName(a), count: quantity(a) });
            }
        });
    }
    for (const t of traits(unit)) {
        walk(t, (a) => {
            if (isWarlordTrait(a))
                is_warlord = true;
        });
    }
    return {
        raw_name: displayName(unit),
        is_character: isCharacterAsset(unit),
        model_count: modelCount(unit),
        points: pointsOf(unit),
        is_warlord,
        enhancement_raw_name,
        enhancement_points,
        wargear,
    };
}
/** Resolve the snapshot Asset tree from an envelope, preferring the explicit
 * `snapshot` field but falling through to the history-present roster. */
function snapshotOf(env) {
    const snap = asObject(env.snapshot);
    if (snap)
        return snap;
    const history = asObject(env.history);
    const present = history && asObject(history.present);
    if (present) {
        const present_roster = asObject(present.roster);
        if (present_roster)
            return present_roster;
    }
    return null;
}
function isRosterizerEnvelope(decoded) {
    const env = asObject(decoded);
    if (!env)
        return false;
    if (!asObject(env.rulebook))
        return false;
    return snapshotOf(env) !== null;
}
/** Find the first child Asset with the given classification, if any. */
function findChildByClass(asset, cls) {
    for (const c of included(asset)) {
        if (classOf(c) === cls)
            return c;
    }
    return null;
}
function parseLimit(label) {
    if (!label)
        return null;
    const match = POINTS_LIMIT.exec(label);
    if (!match)
        return null;
    return Number.parseInt(match[1].replace(/,/g, ""), 10);
}
export const rosterizerAdapter = {
    id: "rosterizer",
    matches(decoded) {
        return isRosterizerEnvelope(decoded);
    },
    parse(decoded) {
        if (!isRosterizerEnvelope(decoded)) {
            throw new Error("rosterizer: payload is not a Rosterizer roster envelope");
        }
        const snapshot = snapshotOf(decoded);
        if (snapshot === null) {
            throw new Error("rosterizer: envelope has no snapshot or history.present.roster");
        }
        // Treat the snapshot as the roster root regardless of its `item` value —
        // some exports root at `Roster§Roster`, others at the faction itself.
        const root = snapshot;
        // Roster-level metadata children. Faction and detachment come from the
        // first child Asset of their respective classification; battle size the
        // same way. Walk the whole tree (rather than just root.assets.included)
        // so nested-force shapes still pick up the markers.
        let faction_raw_name = null;
        let detachment_raw_name = null;
        let battle_size_raw = null;
        const factions = [];
        walk(root, (a) => {
            const cls = classOf(a);
            if (cls === CLS_FACTION) {
                const name = displayName(a);
                if (!factions.includes(name))
                    factions.push(name);
                faction_raw_name ??= name;
            }
            else if (cls === CLS_DETACHMENT) {
                detachment_raw_name ??= displayName(a);
            }
            else if (cls === CLS_BATTLE_SIZE) {
                battle_size_raw ??= displayName(a);
            }
        });
        // Allow the rulebook envelope to carry a battle-size override (e.g.
        // `rulebook.notes: "2000 Point limit"`) — strictly optional.
        if (battle_size_raw === null) {
            const rulebook = asObject(decoded.rulebook);
            // Intentionally not reading rulebook.notes — it may carry prose.
            void rulebook;
        }
        // Collect units: any Unit/Squad asset anywhere in the tree, excluding
        // ones nested under another unit (those are attached leaders we'll fold
        // into leader_attachment via the resolver — but ParsedUnit doesn't model
        // them yet, so for v1 every Unit asset becomes a top-level ParsedUnit).
        const units = [];
        const collectUnits = (a, underUnit) => {
            if (isUnitAsset(a) && !underUnit) {
                units.push(parseUnit(a));
                for (const c of included(a))
                    collectUnits(c, true);
                for (const c of traits(a))
                    collectUnits(c, true);
                return;
            }
            if (isUnitAsset(a) && underUnit) {
                // Nested unit (leader on a body, body on a leader, etc.) — emit it as
                // its own top-level ParsedUnit so the resolver can match its id and
                // the leader-attachment inference pass can link the two.
                units.push(parseUnit(a));
                return;
            }
            for (const c of included(a))
                collectUnits(c, underUnit);
            for (const c of traits(a))
                collectUnits(c, underUnit);
        };
        collectUnits(root, false);
        // Roster-level total: prefer an explicit Points stat on the root, else
        // sum every unit's (base + enhancement) contribution.
        const total_reported = pointsOf(root);
        let total_computed = 0;
        for (const u of units) {
            total_computed += u.points ?? 0;
            total_computed += u.enhancement_points ?? 0;
        }
        const env = decoded;
        const rulebook = asObject(env.rulebook);
        const generated_by = rulebook ? asString(rulebook.name) ?? asString(rulebook.url) : null;
        const name = displayName(root) || asString(rulebook?.name) || "Imported roster";
        return {
            name,
            generated_by,
            faction_raw_name,
            detachment_raw_name,
            battle_size_raw,
            declared_limit: parseLimit(battle_size_raw),
            total_reported,
            total_computed,
            units,
            multi_force: factions.length > 1,
        };
    },
};
// Internals re-exported for the symmetric exporter and unit tests.
export const _internals = {
    CLS_ROSTER,
    CLS_FACTION,
    CLS_DETACHMENT,
    CLS_UNIT,
    CLS_WEAPON,
    CLS_ENHANCEMENT,
    CLS_BATTLE_SIZE,
    CLS_TRAIT,
    DSG_WARLORD,
    POINTS_STAT_KEYS,
    splitItem,
    displayName,
    classOf,
    findChildByClass,
};
//# sourceMappingURL=rosterizer.js.map