//! Offline Source-2 **SchemaSystem** reader: recover class instance sizes, field offsets and base
//! classes straight from Valve's own reflection tables in a stripped `.so` — making the field-offset
//! half of gamedata *deterministic* (no fingerprint carry-forward, no "verify this guess").
//!
//! Source 2 emits, as static data, a `SchemaClassInfoData_t` per registered class (its name, size,
//! field array, base array) plus a `SchemaClassFieldData_t` per field (name, type, offset). The
//! struct layouts here are the authoritative LP64 layouts from hl2sdk-cs2
//! `public/schemasystem/schematypes.h`.
//!
//! Root discovery is reloc-driven, mirroring `rtti::enumerate_vtables`: every class name pointer is
//! a relocation, so we treat each reloc slot as a candidate `m_pszName` field, read the struct just
//! below it, and validate (sane size + field count, a real fields pointer, and a first field named
//! `m_…` — Source 2's universal member-prefix, which alone rejects essentially all false positives).
//! Every pointer is read through `CodeImage::read_ptr`, so `.data.rel.ro` slots (zero on disk) come
//! back as their true as-loaded values.

use crate::elf::CodeImage;
use crate::profile::GameProfile;
use crate::{live, model};
use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;

/// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t /
/// SchemaBaseClassInfoData_t, LP64 — hl2sdk-cs2 public/schemasystem/schematypes.h). Grouped into one
/// swappable value because this layout tracks the engine BUILD ERA (Valve reshapes these structs across
/// engine updates), NOT the game. That makes it orthogonal to `GameProfile`: a future per-era detector
/// ships several `SchemaLayout`s and picks one per binary. Today there is exactly one — `CURRENT_LAYOUT`,
/// the source of truth the rest of this module and the live oracle read through.
pub struct SchemaLayout {
    pub ci_binding: u64, // CSchemaClassInfo* m_pSchemaBinding (0 on disk, populated at runtime)
    pub ci_name: u64,    // const char* m_pszName
    pub ci_size: u64,    // int m_nSize
    pub ci_field_count: u64, // uint16 m_nFieldCount
    pub ci_base_count: u64, // uint8 m_nBaseClassCount
    pub ci_fields: u64,  // SchemaClassFieldData_t* m_pFields
    pub ci_bases: u64,   // SchemaBaseClassInfoData_t* m_pBaseClasses
    pub f_name: u64,     // SchemaClassFieldData_t::m_pszName
    pub f_offset: u64,   // SchemaClassFieldData_t::m_nSingleInheritanceOffset
    pub f_stride: u64,   // sizeof(SchemaClassFieldData_t)
    pub b_offset: u64,   // SchemaBaseClassInfoData_t::m_nOffset
    pub b_class: u64,    // SchemaBaseClassInfoData_t::m_pClass
    pub b_stride: u64,   // sizeof(SchemaBaseClassInfoData_t)
    // ---- CSchemaType: a SECOND runtime struct, reachable only from a live process ----
    // `SchemaClassFieldData_t::m_pType` points at it, and the typed-netvars walk reads the type's name and
    // category through it. It belongs here for the same reason the rest does: this is engine-ERA layout
    // Valve reshapes across builds — kept beside the offline offsets so a reshape can't pass every offline
    // check and still ship a netvars file full of empty types.
    pub f_type: u64,      // SchemaClassFieldData_t::m_pType
    pub ty_name: u64,     // CSchemaType::m_pszName
    pub ty_category: u64, // CSchemaType::m_eTypeCategory (low byte)
}

/// The one layout in service — current CS2/Source-2 engine era.
pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout {
    ci_binding: 0,
    ci_name: 8,
    ci_size: 32,
    ci_field_count: 36,
    ci_base_count: 41,
    ci_fields: 48,
    ci_bases: 56,
    // Every displacement below is added to a FILE-CONTROLLED pointer, so each use wraps rather than
    // panicking under the overflow-checked fuzz build. That includes the ones that are 0 today: they are
    // layout values, revised when Valve reshapes the struct, and "safe because this constant happens to
    // be zero" is a trap that springs on the revision rather than on the code that introduced it.
    f_name: 0,
    f_offset: 16,
    f_stride: 32,
    b_offset: 0,
    b_class: 8,
    b_stride: 16,
    f_type: 8,
    ty_name: 8,
    ty_category: 24,
};

// The offsets projected as module consts — the stable interface the parser (below) and the runtime
// oracle (`produce::verify_live_cmd` via `schema::CI_*` / `F_*`) read. Sourced from `CURRENT_LAYOUT` so it stays the
// single source of truth; a per-era swap changes only the const above.
pub const CI_BINDING: u64 = CURRENT_LAYOUT.ci_binding;
pub const CI_NAME: u64 = CURRENT_LAYOUT.ci_name;
pub const CI_SIZE: u64 = CURRENT_LAYOUT.ci_size;
pub const CI_FIELD_COUNT: u64 = CURRENT_LAYOUT.ci_field_count;
const CI_BASE_COUNT: u64 = CURRENT_LAYOUT.ci_base_count;
pub const CI_FIELDS: u64 = CURRENT_LAYOUT.ci_fields;
const CI_BASES: u64 = CURRENT_LAYOUT.ci_bases;
const F_NAME: u64 = CURRENT_LAYOUT.f_name;
pub const F_OFFSET: u64 = CURRENT_LAYOUT.f_offset;
pub const F_STRIDE: u64 = CURRENT_LAYOUT.f_stride;
pub const F_TYPE: u64 = CURRENT_LAYOUT.f_type;
pub const TY_NAME: u64 = CURRENT_LAYOUT.ty_name;
pub const TY_CATEGORY: u64 = CURRENT_LAYOUT.ty_category;
const B_OFFSET: u64 = CURRENT_LAYOUT.b_offset;
const B_CLASS: u64 = CURRENT_LAYOUT.b_class;
const B_STRIDE: u64 = CURRENT_LAYOUT.b_stride;

pub struct SchemaField {
    pub name: String,
    pub offset: i32,
}

pub struct SchemaBase {
    pub name: String,
    pub offset: u32,
}

/// One registered Source-2 class recovered from the schema tables.
pub struct SchemaClass {
    pub name: String,
    pub class_info: u64, // vaddr of the SchemaClassInfoData_t
    pub name_ptr: u64,   // reloc-resolved vaddr of the name string (for live cross-check)
    pub size: i32,       // instance size in bytes
    pub bases: Vec<SchemaBase>,
    pub fields: Vec<SchemaField>,
}

impl SchemaClass {
    /// The primary (offset-0) base class name, if any — for cross-checking against the RTTI chain.
    pub fn primary_base(&self) -> Option<&str> {
        self.bases
            .iter()
            .find(|b| b.offset == 0)
            .map(|b| b.name.as_str())
    }
}

/// A schema type name: an identifier plus the template/namespace punctuation Source 2 uses.
fn is_type_name(s: &str) -> bool {
    let b = s.as_bytes();
    if b.is_empty() || b.len() >= 256 {
        return false;
    }
    if !(b[0].is_ascii_alphabetic() || b[0] == b'_') {
        return false;
    }
    s.chars().all(|c| {
        c.is_ascii_alphanumeric()
            || matches!(c, '_' | ':' | '<' | '>' | ',' | ' ' | '*' | '&' | '[' | ']')
    })
}

/// Enumerate every registered class in `img` via the SchemaSystem tables — the whole-binary schema
/// inventory. Sorted by class name.
pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
    let mut out = Vec::new();
    for (slot, val) in img.reloc_slots() {
        if slot < 8 {
            continue;
        }
        // Candidate: `slot` is a class's m_pszName field, so `val` -> the class name string.
        let Some(name) = img.read_c_string(val) else {
            continue;
        };
        if !is_type_name(&name) {
            continue;
        }
        // No de-dup guard — `reloc_slots` iterates a slot-keyed map, so `slot - 8` is already unique.
        let base = slot - 8;
        if let Some(cls) = parse_class(img, base, &name, val) {
            out.push(cls);
        }
    }
    // By NAME, then by the record's own address. The walk iterates `reloc_slots()`, which is a HashMap,
    // so the collection order is hash order; a sort on name alone is stable and therefore leaves ties —
    // two libraries registering one class — resolved by that hash order, in a byte-reproducible artifact.
    out.sort_by(|a, b| (&a.name, a.class_info).cmp(&(&b.name, b.class_info)));
    out
}

/// One registered Source-2 enum recovered from the schema tables — the semantic vocabulary
/// (`MoveType_t`, `gear_slot_t`, `DamageTypes_t`) that a raw field offset and an integer width cannot
/// supply on their own.
pub struct SchemaEnum {
    pub name: String,
    /// Underlying integer width in bytes — the binding records it, so a byte-sized enum
    /// (`MoveType_t`) is distinguishable from a word-sized one (`gear_slot_t`) without inference.
    pub size: u8,
    pub align: u8,
    /// Enumerators in DECLARATION order (names are unique; values are not — aliases like
    /// `MOVETYPE_LAST` / `MOVETYPE_INVALID` legitimately share one).
    pub values: Vec<(String, i64)>,
}

// A `CSchemaEnumBinding`, relative to the slot holding its name pointer.
const EB_TYPE_NAME: u64 = 0; // char* — the enum's type name (the reloc slot this is found by)
const EB_WIDTH: u64 = 16; // u8 size, u8 alignment, u16 flags, u32 enumerator count
const EB_VALUES: u64 = 24; // -> the enumerator array
const EV_STRIDE: u64 = 32; // one enumerator: char* name, i64 value, then metadata
const EV_VALUE: u64 = 8;
/// Enumerator-count sanity bound. The largest real CS2 enum is ~100 values; this only has to reject a
/// field that isn't a count at all before it drives an allocation.
const EB_MAX_VALUES: u32 = 4096;

/// Enumerate every registered enum in `img`, given the classes [`enumerate_schema`] already recovered.
/// Same reloc-driven discovery: an enum binding is found by the slot holding its type-name pointer, then
/// accepted only if the width/count word and the enumerator array both read as what they claim to be —
/// so a layout change yields fewer enums, never wrong ones. Sorted by name.
///
/// **A class's FIELD descriptor is byte-compatible with an enum binding**, which is why `classes` is a
/// parameter rather than a convenience. `SchemaClassFieldData_t` is `{ name, type, offset, metadataCount,
/// metadata }`: read as an enum binding, the name reads as a type name, the low bytes of the offset read
/// as a plausible size/alignment, the metadata count reads as an enumerator count, and the metadata array
/// — `{ name, data }` pairs — reads as enumerators. Every field carrying exactly one metadata tag at a
/// field offset whose low two bytes are both powers of two therefore fits, and the result would be an
/// enum that does not exist, named after a member, whose one "value" is the ADDRESS of a documentation
/// string and therefore differs between runs of the same build.
///
/// Two independent structural facts reject them, and both are needed — measured over 1,016 CS2 and 1,490
/// Dota candidates, they catch 40 apiece with zero real enums lost, and neither catches all 40 alone:
///
/// 1. **The record sits inside a class's field array**, at a `F_STRIDE` boundary. That is not a heuristic
///    — the SchemaSystem states that this address is that class's Nth field.
/// 2. **An enumerator's value is a relocation.** An enum value is a compile-time literal, so it is never
///    relocated; a metadata entry's second word is a pointer, so it always is. This is what catches a
///    field whose owning class the class walk itself rejected, leaving no array to fall inside.
pub fn enumerate_enums(img: &CodeImage, classes: &[SchemaClass]) -> Vec<SchemaEnum> {
    // The address ranges class field descriptors occupy, sorted so membership is a binary search.
    let mut spans: Vec<(u64, u64)> = classes
        .iter()
        .filter_map(|c| {
            let fp = img.read_ptr(c.class_info.wrapping_add(CI_FIELDS))?;
            (fp != 0).then(|| (fp, fp.wrapping_add(F_STRIDE * c.fields.len() as u64)))
        })
        .collect();
    spans.sort_unstable();
    // Each class owns its own array, so the ranges are disjoint and the last one starting at or before
    // `a` is the only one that can contain it. If that ever stopped holding, the miss would be a fake
    // enum surviving rather than a real one dropped — the same direction every other guard here errs in.
    let in_field_array = |a: u64| {
        let i = spans.partition_point(|&(s, _)| s <= a);
        i > 0 && {
            let (s, e) = spans[i - 1];
            a < e && (a - s).is_multiple_of(F_STRIDE)
        }
    };

    let mut out = Vec::new();
    for (slot, val) in img.reloc_slots() {
        let Some(name) = img.read_c_string(val) else {
            continue;
        };
        // `reloc_slots` iterates a slot-keyed map, so a per-slot de-dup set can reject nothing; the real
        // de-dup is by NAME, at the sort/dedup below (a shared enum is registered by several libraries).
        if !is_type_name(&name) {
            continue;
        }
        let base = slot.wrapping_sub(EB_TYPE_NAME);
        // Clause 1: the SchemaSystem states this address is a class's field descriptor, so it is one.
        if in_field_array(base) {
            continue;
        }
        let Some(w) = img.read_ptr(base.wrapping_add(EB_WIDTH)) else {
            continue;
        };
        let (size, align, count) = (w as u8, (w >> 8) as u8, (w >> 32) as u32);
        if !matches!(size, 1 | 2 | 4 | 8)
            || !matches!(align, 1 | 2 | 4 | 8)
            || count == 0
            || count > EB_MAX_VALUES
        {
            continue;
        }
        let Some(arr) = img
            .read_ptr(base.wrapping_add(EB_VALUES))
            .filter(|&a| a != 0)
        else {
            continue;
        };
        // Every enumerator must read cleanly; a partial read means this was not an enum binding.
        let mut values = Vec::with_capacity(count as usize);
        for i in 0..u64::from(count) {
            let rec = arr.wrapping_add(i.wrapping_mul(EV_STRIDE));
            let (Some(n), Some(v)) = (
                img.read_ptr(rec).and_then(|p| img.read_c_string(p)),
                img.read_i64(rec.wrapping_add(EV_VALUE)),
            ) else {
                break;
            };
            // Clause 2: a relocated "value" is a pointer, so these are `{ name, data }` metadata
            // entries and not enumerators. Rejects the whole record — one pointer among the values
            // means the array is the wrong kind, not that one enumerator is odd.
            if n.is_empty() || img.is_reloc_slot(rec.wrapping_add(EV_VALUE)) {
                break;
            }
            values.push((n, v));
        }
        if values.len() == count as usize {
            out.push(SchemaEnum {
                name,
                size,
                align,
                values,
            });
        }
    }
    // Same reason as `enumerate_schema`, and it matters MORE here because the `dedup_by` below then keeps
    // whichever row sorted first: with a name-only sort that survivor was picked by hash order. Enums have
    // no record address on the struct, so the tiebreak is the content that distinguishes two accounts of
    // one name — width, alignment, then the enumerator list.
    out.sort_by(|a, b| {
        (&a.name, a.size, a.align, &a.values).cmp(&(&b.name, b.size, b.align, &b.values))
    });
    out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
    out
}

fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<SchemaClass> {
    let size = img.read_i32(base.wrapping_add(CI_SIZE))?;
    if size <= 0 || size >= (1 << 23) {
        return None;
    }
    let field_count = img.read_u16(base.wrapping_add(CI_FIELD_COUNT))?;
    if field_count == 0 || field_count >= 6000 {
        return None;
    }
    let fields_ptr = img.read_ptr(base.wrapping_add(CI_FIELDS))?;
    if fields_ptr == 0 {
        return None;
    }
    // Discriminator: a real schema class's first field is `m_…`. This alone rejects the stray reloc
    // slots that happen to point at an identifier-shaped string but aren't class bindings.
    let first = img
        .read_ptr(fields_ptr)
        .and_then(|p| img.read_c_string(p))?;
    if !first.starts_with("m_") {
        return None;
    }

    let mut fields = Vec::with_capacity(field_count as usize);
    for i in 0..field_count as u64 {
        let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE));
        let Some(fname) = img
            .read_ptr(fe.wrapping_add(F_NAME))
            .and_then(|p| img.read_c_string(p))
        else {
            break;
        };
        let offset = img.read_i32(fe.wrapping_add(F_OFFSET)).unwrap_or(0);
        fields.push(SchemaField {
            name: fname,
            offset,
        });
    }

    let base_count = img.read_u8(base.wrapping_add(CI_BASE_COUNT)).unwrap_or(0);
    let bases_ptr = img.read_ptr(base.wrapping_add(CI_BASES)).unwrap_or(0);
    let mut bases = Vec::new();
    if bases_ptr != 0 {
        for i in 0..base_count as u64 {
            let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE));
            let offset = img.read_u32(be.wrapping_add(B_OFFSET)).unwrap_or(0);
            let bcls = img.read_ptr(be.wrapping_add(B_CLASS)).unwrap_or(0);
            if bcls == 0 {
                continue;
            }
            if let Some(bn) = img
                .read_ptr(bcls.wrapping_add(CI_NAME))
                .and_then(|p| img.read_c_string(p))
            {
                bases.push(SchemaBase { name: bn, offset });
            }
        }
    }

    Some(SchemaClass {
        name: name.to_string(),
        class_info: base,
        name_ptr,
        size,
        bases,
        fields,
    })
}

// ─── Live type walk (the typed schema) ──────────────────────────────────────────────────────────
// The offline reader above recovers class layouts (names + field offsets) from the static reflection
// tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so
// `live_schema` attaches to a running process, reads the types back, and builds the typed
// the artifact's `schema` section (`model::Schema`) directly — no `sdk.json` intermediate.

/// FNV-1a (32-bit). The Source-2 schema field/class name hash: a field's runtime lookup key is
/// `(fnv1a32(class_name) << 32) | fnv1a32(field_name)` (field name keeps its `m_` prefix). Confirmed
/// against swiftlys2's own generated hashes.
fn fnv1a32(s: &str) -> u32 {
    let mut h: u32 = 0x811c9dc5;
    for b in s.bytes() {
        h = (h ^ b as u32).wrapping_mul(0x0100_0193);
    }
    h
}

/// Best-effort byte size of a builtin schema type (codegen doesn't require it, but it's cheap).
fn builtin_size(t: &str) -> i32 {
    match t {
        "int8" | "uint8" | "char" | "bool" => 1,
        "int16" | "uint16" => 2,
        "int32" | "uint32" | "float32" => 4,
        "int64" | "uint64" | "float64" | "double" => 8,
        _ if t.ends_with('*') => 8,
        _ => 0,
    }
}

/// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed
/// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each
/// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe
/// precedence-first (the earlier lib in `libs` wins). This is the artifact's `schema` section — the shipped SDK
/// material (`source2rosetta-gen` renders it on demand).
pub(crate) fn live_schema(
    prof: &GameProfile,
    pid: u32,
    dir: &Path,
    source_build: &str,
) -> Result<model::Schema> {
    use model::{Field, Schema, SchemaMeta};
    let live = live::LiveProcess::attach(pid)?;
    let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
    let (mut typed, mut untyped) = (0usize, 0usize);
    let mut seen: HashSet<String> = HashSet::new();
    let mut enums: BTreeMap<String, model::EnumDef> = BTreeMap::new();
    // The schema states each registered class's instance size — the exact half of the layout picture.
    let mut registered_sizes: BTreeMap<String, usize> = BTreeMap::new();
    // The base graph the schema already recovers — exported, so a consumer can resolve an inherited
    // field, and consulted here so the SysV verdict sees inherited members.
    let mut bases: BTreeMap<String, Vec<model::BaseClass>> = BTreeMap::new();
    let mut nlibs = 0usize;
    for &lib in prof.libs {
        let Ok(img) = crate::locate::load_lib(dir, lib) else {
            continue;
        };
        let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
        nlibs += 1;
        // ONE class walk per library, shared by both consumers below — the enum walk needs it to tell a
        // field descriptor from an enum binding, and walking the reflection tables twice per image is
        // what a large game's memory ceiling notices first.
        let schema_classes = enumerate_schema(&img);
        // Enum bindings are static, so they come from the IMAGE — no process read, unlike field types.
        // First library wins, matching the class precedence: a shared enum has one definition.
        for e in enumerate_enums(&img, &schema_classes) {
            enums.entry(e.name).or_insert_with(|| model::EnumDef {
                size: e.size,
                values: e
                    .values
                    .into_iter()
                    .map(|(name, value)| model::EnumValue { name, value })
                    .collect(),
            });
        }
        for c in &schema_classes {
            // a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
            if !seen.insert(c.name.clone()) {
                continue;
            }
            // fields array is static; each record's m_pType is runtime-resolved -> read it from the process
            let fields_ptr = img.read_ptr(c.class_info + CI_FIELDS).unwrap_or(0);
            let mut fmap: BTreeMap<String, Field> = BTreeMap::new();
            for (i, f) in c.fields.iter().enumerate() {
                let rec = base
                    .wrapping_add(fields_ptr)
                    .wrapping_add((i as u64).wrapping_mul(F_STRIDE));
                let mptype = live.read_u64(rec.wrapping_add(F_TYPE)).unwrap_or(0);
                // a resolved type is a real pointer; on-disk placeholders are tiny/tagged values
                let (type_name, cat) = if mptype > 0x10000 {
                    let name = live
                        .read_u64(mptype.wrapping_add(TY_NAME))
                        .ok()
                        .and_then(|q| live.read_cstr(q).ok())
                        .unwrap_or_default();
                    let cat = live
                        .read_u16(mptype.wrapping_add(TY_CATEGORY))
                        .map(|v| (v & 0xff) as u8)
                        .unwrap_or(0xff);
                    (name, cat)
                } else {
                    (String::new(), 0xffu8)
                };
                let ty = type_name.replace(' ', ""); // codegen strips spaces anyway
                // count typed/untyped on the SPACE-STRIPPED type (what the netvars meta reflects), so a
                // whitespace-only runtime name counts as untyped.
                if ty.is_empty() {
                    untyped += 1;
                } else {
                    typed += 1;
                }
                let kind = match cat {
                    1 => model::FieldKind::Ptr,
                    3 => model::FieldKind::FixedArray,
                    _ => model::FieldKind::Ref, // builtin / atomic / declared class / declared enum
                };
                let name_hash = ((fnv1a32(&c.name) as u64) << 32) | fnv1a32(&f.name) as u64;
                fmap.insert(
                    f.name.clone(),
                    Field {
                        offset: f.offset,
                        ty: ty.clone(),
                        kind,
                        size: builtin_size(&ty) as usize,
                        name_hash,
                    },
                );
            }
            if let Ok(sz) = usize::try_from(c.size) {
                registered_sizes.insert(c.name.clone(), sz);
            }
            if !c.bases.is_empty() {
                bases.insert(
                    c.name.clone(),
                    c.bases
                        .iter()
                        .map(|b| model::BaseClass {
                            name: b.name.clone(),
                            offset: b.offset,
                        })
                        .collect(),
                );
            }
            classes.insert(c.name.clone(), fmap);
        }
    }
    let (types, cal) = derive_type_layouts(&classes, &registered_sizes, &bases);
    // A size is only useful to a caller once it reaches the FIELD: `size` was zero for every aggregate,
    // which reads as "unknown" and is exactly what the layout pass now answers.
    for fields in classes.values_mut() {
        for f in fields.values_mut() {
            if f.size == 0
                && let Some(t) = base_type(&f.ty).and_then(|b| types.get(b))
            {
                // The field's EXTENT, so a consumer can bound a read: a fixed array spans
                // element x count. Writing the element size here would understate `char[128]` as 1.
                f.size = t.size.saturating_mul(array_len(&f.ty));
            }
        }
    }
    let derived_sizes = classes
        .values()
        .flat_map(|c| c.values())
        .filter(|f| f.size > 0)
        .count();
    eprintln!(
        "typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved; \
         {} enums / {} enumerators; {} type layouts ({derived_sizes}/{} fields sized, \
         field-gap calibration {}/{} exact)",
        classes.len(),
        enums.len(),
        enums.values().map(|e| e.values.len()).sum::<usize>(),
        types.len(),
        typed + untyped,
        cal.exact,
        cal.checked
    );
    Ok(Schema {
        meta: SchemaMeta {
            game_key: prof.game_key.to_string(),
            source_build: source_build.to_string(),
            typed,
            untyped,
            enums: enums.len(),
            types: types.len(),
        },
        classes,
        bases,
        enums,
        types,
    })
}

// ══════════════════════════════════════════════════════════════════════════════════════════════
// Type layouts — what a caller needs to PASS a value, which an offset alone cannot supply
// ══════════════════════════════════════════════════════════════════════════════════════════════

/// SysV classification for the engine value types the SchemaSystem does NOT register, and whose size
/// alone cannot settle how they travel.
///
/// At 16 bytes or less an aggregate goes in SSE registers when every member is floating-point and in
/// integer registers otherwise, and no derived size distinguishes those two. Above 16 bytes the size
/// settles it, so nothing needs declaring. This is therefore the ONE place the deriver declares rather
/// than derives, deliberately kept to a closed set of engine primitives — each entry is what the type
/// demonstrably IS, not a guess: the math types are plain float aggregates, and everything else here is
/// a pointer, a handle or a packed integer.
const UNREGISTERED_CLASSES: &[(&str, model::SysvClass)] = &[
    // All-float aggregates — SSE. `Vector` by value costs TWO SSE registers; by reference, one integer.
    ("Vector", model::SysvClass::Sse),
    ("VectorWS", model::SysvClass::Sse),
    ("Vector2D", model::SysvClass::Sse),
    ("Vector4D", model::SysvClass::Sse),
    ("QAngle", model::SysvClass::Sse),
    ("Quaternion", model::SysvClass::Sse),
    ("RadianEuler", model::SysvClass::Sse),
    ("QuaternionStorage", model::SysvClass::Sse),
    // Pointers, handles and packed integers — INTEGER.
    ("CUtlString", model::SysvClass::Integer),
    ("CUtlSymbolLarge", model::SysvClass::Integer),
    ("CUtlSymbol", model::SysvClass::Integer),
    ("CGlobalSymbol", model::SysvClass::Integer),
    ("CUtlStringToken", model::SysvClass::Integer),
    ("CHandle", model::SysvClass::Integer),
    ("CEntityHandle", model::SysvClass::Integer),
    ("CStrongHandle", model::SysvClass::Integer),
    ("CWeakHandle", model::SysvClass::Integer),
    ("CGameSoundEventName", model::SysvClass::Integer),
    ("Color", model::SysvClass::Integer),
    ("CTransform", model::SysvClass::Memory), // 32 bytes; stated for clarity, size settles it anyway
];

/// The SysV boundary: an aggregate above this is passed in memory, so its size settles its class.
const SYSV_REGISTER_LIMIT: usize = 16;
/// A field-gap size is accepted only with this much agreement across observations — the modal gap has to
/// dominate, or the "next field" is padding/union noise rather than this field's extent.
const GAP_AGREEMENT: f64 = 0.8;
/// …and only with at least this many observations, so one lucky class cannot mint a size.
const GAP_MIN_OBS: usize = 4;

/// A builtin's SysV class. The floating types travel in SSE registers, every other builtin in integer
/// ones — a property of the ABI, not of Valve's code, which is why it is stated here rather than derived.
fn builtin_sysv(t: &str) -> Option<model::SysvClass> {
    match t {
        "float32" | "float64" | "double" => Some(model::SysvClass::Sse),
        "int8" | "uint8" | "char" | "bool" | "int16" | "uint16" | "int32" | "uint32" | "int64"
        | "uint64" => Some(model::SysvClass::Integer),
        _ => None,
    }
}

/// How well the field-gap inference reproduced the sizes that are known exactly — the same free-oracle
/// idea as the entity-IO ABI check: the builtins have an independently known size, so running the
/// inference over them and comparing is a per-build test of the inference itself, not an assumption.
pub struct GapCalibration {
    pub checked: usize,
    pub exact: usize,
}

/// The bare type name behind a field's declared type: array suffix stripped, template arguments dropped.
/// `None` for a pointer (its size is the pointer's, and it says nothing about the pointee) or a bitfield.
fn base_type(ty: &str) -> Option<&str> {
    let t = ty.trim().split('[').next()?.trim();
    if t.ends_with('*') || t.starts_with("bitfield") || t.is_empty() {
        return None;
    }
    Some(t.split('<').next()?.trim())
}

/// Every type NAME a declared field type mentions: the outer type plus each template argument, since an
/// inner type is a real type a consumer must know — `CUtlLeanVector<CPulseRuntimeMethodArg>` is how the
/// element type of a Pulse method's argument list is spelled, and stripping the template arguments loses it.
fn mentioned_types(ty: &str) -> Vec<&str> {
    let mut out = Vec::new();
    if let Some(b) = base_type(ty) {
        out.push(b);
    }
    // Template arguments, comma-split at depth 1 so a nested template stays with its parent.
    if let Some(open) = ty.find('<') {
        let inner = &ty[open + 1..ty.rfind('>').unwrap_or(ty.len())];
        let (mut depth, mut start) = (0usize, 0usize);
        for (i, c) in inner.char_indices() {
            match c {
                '<' | '(' | '[' => depth += 1,
                '>' | ')' | ']' => depth = depth.saturating_sub(1),
                ',' if depth == 0 => {
                    out.extend(mentioned_types(&inner[start..i]));
                    start = i + 1;
                }
                _ => {}
            }
        }
        out.extend(mentioned_types(&inner[start..]));
    }
    out
}

/// The declared array length of a field type (`float32[3]` -> 3), else 1.
fn array_len(ty: &str) -> usize {
    ty.rsplit_once('[')
        .and_then(|(_, n)| n.strip_suffix(']'))
        .and_then(|n| n.trim().parse::<usize>().ok())
        .filter(|&n| n > 0)
        .unwrap_or(1)
}

/// Is every member of `ty`, inherited members included, a floating-point value? `None` when the answer
/// cannot be established — an unknown base, or a member whose own type is not resolvable — because
/// "unknown" and "not all float" are different answers and only one of them is safe to act on.
fn all_float(
    ty: &str,
    classes: &BTreeMap<String, BTreeMap<String, model::Field>>,
    bases: &BTreeMap<String, Vec<model::BaseClass>>,
    depth: usize,
) -> Option<bool> {
    if depth > 8 {
        return None; // pathological or cyclic hierarchy — decline rather than guess
    }
    let fields = classes.get(ty)?;
    for b in bases.get(ty).map(Vec::as_slice).unwrap_or_default() {
        if !all_float(&b.name, classes, bases, depth + 1)? {
            return Some(false);
        }
    }
    // A class with no members of its own and no bases tells us nothing about how it travels.
    if fields.is_empty() && bases.get(ty).is_none_or(Vec::is_empty) {
        return None;
    }
    Some(
        fields
            .values()
            .all(|f| matches!(base_type(&f.ty), Some("float32" | "float64"))),
    )
}

/// Recover a size and a SysV class for every type the schema's fields refer to.
///
/// Two independent routes, and which one produced a given answer is recorded rather than blurred:
/// a REGISTERED class states its own instance size, and everything else is inferred from the distance to
/// the next field — schema fields are laid out in offset order, so that gap IS the field's extent. The
/// inference is calibrated on the types whose size is independently known: every primitive
/// (`float32`, `int64`, …) comes back exact.
pub fn derive_type_layouts(
    classes: &BTreeMap<String, BTreeMap<String, model::Field>>,
    registered_sizes: &BTreeMap<String, usize>,
    bases: &BTreeMap<String, Vec<model::BaseClass>>,
) -> (BTreeMap<String, model::TypeLayout>, GapCalibration) {
    // base type -> observed per-element gap -> how many times it was seen
    let mut gaps: BTreeMap<&str, BTreeMap<usize, usize>> = BTreeMap::new();
    for fields in classes.values() {
        let mut by_off: Vec<(&model::Field, &str)> =
            fields.values().map(|f| (f, f.ty.as_str())).collect();
        by_off.sort_by_key(|(f, _)| f.offset);
        for w in by_off.windows(2) {
            let (f, ty) = w[0];
            let gap = w[1].0.offset - f.offset;
            // A non-positive gap is a union or an overlapping bitfield, not an extent.
            let (Some(base), true) = (base_type(ty), gap > 0) else {
                continue;
            };
            let n = array_len(ty);
            if gap as usize % n != 0 {
                continue; // the gap does not divide into the declared element count — not this field's
            }
            *gaps
                .entry(base)
                .or_default()
                .entry(gap as usize / n)
                .or_default() += 1;
        }
    }

    let declared: BTreeMap<&str, model::SysvClass> = UNREGISTERED_CLASSES.iter().copied().collect();
    let mut out = BTreeMap::new();
    // Every type any field refers to — a type used only behind a pointer still deserves an entry when
    // its size is known from the schema.
    let mut wanted: BTreeSet<&str> = BTreeSet::new();
    for fields in classes.values() {
        for f in fields.values() {
            wanted.extend(mentioned_types(&f.ty));
        }
    }
    wanted.extend(gaps.keys().copied());
    // Every registered class, whether or not any field happens to name it — the schema states its size, so
    // withholding the entry would be losing an answer we already hold.
    wanted.extend(registered_sizes.keys().map(String::as_str));

    let mut cal = GapCalibration {
        checked: 0,
        exact: 0,
    };
    for ty in wanted {
        let builtin = builtin_size(ty);
        // Where a size is known exactly, CHECK the inference against it rather than using the inference.
        if builtin > 0
            && let Some(hist) = gaps.get(ty)
            && let Some((&sz, _)) = hist.iter().max_by_key(|&(_, n)| *n)
        {
            cal.checked += 1;
            cal.exact += usize::from(sz == builtin as usize);
        }
        let (size, source, obs, agree) = match (builtin, registered_sizes.get(ty)) {
            // A builtin's size is fixed by the ABI.
            (b, _) if b > 0 => (b as usize, model::LayoutSource::Declared, None, None),
            // The schema states a registered class's size — no inference needed.
            (_, Some(&sz)) => (sz, model::LayoutSource::Schema, None, None),
            _ => {
                let Some(hist) = gaps.get(ty) else { continue };
                let total: usize = hist.values().sum();
                let (&sz, &n) = hist.iter().max_by_key(|&(_, n)| *n).expect("non-empty");
                if total < GAP_MIN_OBS || (n as f64) < GAP_AGREEMENT * total as f64 {
                    continue; // too thin or too contested to state a size
                }
                (sz, model::LayoutSource::FieldGap, Some(total), Some(n))
            }
        };
        // Above the register limit the size decides. At or below it, the question is whether every member
        // is floating-point — derivable for a registered class by inspecting its fields, and declared for
        // the closed set of primitives the schema omits.
        let sysv = if let Some(c) = builtin_sysv(ty) {
            c
        } else if size > SYSV_REGISTER_LIMIT {
            model::SysvClass::Memory
        } else if let Some(&c) = declared.get(ty) {
            c
        } else {
            // SSE requires that EVERY member is floating-point — including inherited ones. Judging on
            // a class's own fields alone calls a type SSE whose base contributes the first eightbyte
            // (a pointer), which is the difference between passing it in XMM0 and in RDI.
            match all_float(ty, classes, bases, 0) {
                Some(true) => model::SysvClass::Sse,
                Some(false) => model::SysvClass::Integer,
                None => model::SysvClass::Unknown,
            }
        };
        out.insert(
            ty.to_string(),
            model::TypeLayout {
                size,
                sysv,
                source,
                observations: obs,
                agreement: agree,
            },
        );
    }
    (out, cal)
}
