//! 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, 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,
    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();
    let mut seen = HashSet::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;
        }
        let base = slot - 8;
        if !seen.insert(base) {
            continue;
        }
        if let Some(cls) = parse_class(img, base, &name, val) {
            out.push(cls);
        }
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    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 + F_NAME).and_then(|p| img.read_c_string(p)) else {
            break;
        };
        let offset = img.read_i32(fe + 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 + B_OFFSET).unwrap_or(0);
            let bcls = img.read_ptr(be + B_CLASS).unwrap_or(0);
            if bcls == 0 {
                continue;
            }
            if let Some(bn) = img
                .read_ptr(bcls + 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
// `netvars-<game>.json` (`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 `netvars-<game>.json` — 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 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;
        for c in &enumerate_schema(&img) {
            // 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,
                    },
                );
            }
            classes.insert(c.name.clone(), fmap);
        }
    }
    eprintln!(
        "typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved",
        classes.len()
    );
    Ok(Schema {
        meta: SchemaMeta {
            game_key: prof.game_key.to_string(),
            source_build: source_build.to_string(),
            typed,
            untyped,
        },
        classes,
    })
}
