//! `//go:embed` asset extractor.
//!
//! `embed.FS` is a struct with a single field `files *[]file`. The compiler
//! lays down a generated `[]file` array in read-only data, and the runtime's
//! `file` struct is:
//!
//! ```text
//! type file struct {
//!     name string   // virtual path, e.g. "assets/x.txt" ("dir/" for directories)
//!     data string   // backing bytes in rodata (empty for directories)
//!     hash [16]byte // truncated SHA-256 (zero for directories)
//! }
//! ```
//!
//! On 64-bit that is `16 + 16 + 16 = 48` bytes per entry; on 32-bit, `32`.
//! Source: `src/embed/embed.go` (`FS`, `file`).
//!
//! ## Locating the array without symbols
//!
//! Stripped binaries carry no symbol for the embed variable, so we scan the
//! address space for the `*[]file` **slice header** `(ptr, len, cap)`: a
//! pointer-aligned triple where `len == cap`, `len` is small, and the array at
//! `ptr` parses as exactly `len` well-formed `file` entries (each with a
//! resolvable, path-shaped name; files with resolvable backing bytes; dirs
//! with a trailing `/` and empty data). Requiring the *whole* array to
//! validate makes false positives vanishingly unlikely.
//!
//! ## Limitation
//!
//! The single-file `//go:embed` forms (`embed.String` / `embed.Bytes`) compile
//! to an ordinary `string` / `[]byte` variable with no `files` anchor and are
//! **not** recoverable by this scanner. Only the `embed.FS` (multi-file) form
//! is supported.

use crate::{formats::BinaryContext, structures::util::read_uintptr};

/// One asset recovered from an `embed.FS`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbeddedAsset<'a> {
    /// Virtual path within the `embed.FS` (e.g. `"assets/logo.png"`).
    /// Directories keep their trailing `/`.
    pub path: &'a str,
    /// Whether this entry is a directory (no backing bytes).
    pub is_dir: bool,
    /// The embedded file contents, borrowed from read-only data. Empty for
    /// directories.
    pub data: &'a [u8],
}

/// Upper bounds so adversarial `len` / `name_len` fields cannot blow up memory
/// or wall-clock.
const MAX_ENTRIES_PER_FS: u64 = 1_000_000;
const MAX_NAME_LEN: u64 = 4096;
const MAX_DATA_LEN: u64 = 1 << 31; // 2 GiB per embedded file is already absurd

/// Scan the binary for `embed.FS` assets.
///
/// Returns every asset across all embed filesystems found, de-duplicated by
/// backing array. Empty when the binary embeds nothing (or only uses the
/// unsupported single-file form).
pub fn extract<'a>(ctx: &'a BinaryContext<'a>, ptr_size: u8) -> Vec<EmbeddedAsset<'a>> {
    let p = ptr_size as usize;
    if p != 4 && p != 8 {
        return Vec::new();
    }
    let data = ctx.structure_search_data();
    // file entry = name(string) + data(string) + hash[16] = 4*ptr + 16.
    let entry_size = match p.checked_mul(4).and_then(|x| x.checked_add(16)) {
        Some(s) => s,
        None => return Vec::new(),
    };
    let header_size = match p.checked_mul(3) {
        Some(s) => s,
        None => return Vec::new(),
    };

    let mut out = Vec::new();
    let mut seen_arrays: Vec<u64> = Vec::new();

    let mut off = 0usize;
    while let Some(end) = off.checked_add(header_size) {
        if end > data.len() {
            break;
        }
        // Candidate slice header (ptr, len, cap).
        if let Some(arr_va) = read_slice_header(data, off, ptr_size, entry_size)
            && !seen_arrays.contains(&arr_va.0)
            && let Some(mut assets) =
                parse_file_array(ctx, arr_va.0, arr_va.1, ptr_size, entry_size)
        {
            seen_arrays.push(arr_va.0);
            out.append(&mut assets);
        }
        off = match off.checked_add(p) {
            Some(o) => o,
            None => break,
        };
    }
    out
}

/// Validate a `(ptr, len, cap)` slice-header candidate at `off`. Returns
/// `(array_va, len)` when it could plausibly be a `[]file` header.
fn read_slice_header(data: &[u8], off: usize, ps: u8, entry_size: usize) -> Option<(u64, u64)> {
    let p = ps as usize;
    let ptr = read_uintptr(data, off, ps)?;
    let len = read_uintptr(data, off.checked_add(p)?, ps)?;
    let cap = read_uintptr(data, off.checked_add(p.checked_mul(2)?)?, ps)?;
    if ptr == 0 || len == 0 || len != cap || len > MAX_ENTRIES_PER_FS {
        return None;
    }
    // The whole array must fit within addressable bytes; cheap pre-check using
    // entry_size * len not overflowing.
    (len as usize).checked_mul(entry_size)?;
    Some((ptr, len))
}

/// Parse exactly `count` `file` entries at array VA `arr_va`. Returns `None`
/// (rejecting the candidate) if any entry fails strict validation or the array
/// is not in `embed`'s canonical sort order.
fn parse_file_array<'a>(
    ctx: &'a BinaryContext<'a>,
    arr_va: u64,
    count: u64,
    ps: u8,
    entry_size: usize,
) -> Option<Vec<EmbeddedAsset<'a>>> {
    let p = ps as usize;
    let mut assets = Vec::with_capacity(count.min(64) as usize);
    // The embed `files` list is sorted by (dir, base); enforcing strictly
    // increasing order is the decisive filter that rejects the many unrelated
    // `[]string`-shaped tables a blind scan would otherwise match.
    let mut prev_key: Option<(String, String)> = None;
    for i in 0..count {
        let entry_off = (i as usize).checked_mul(entry_size)?;
        let entry_va = arr_va.checked_add(entry_off as u64)?;
        let entry = ctx.slice_at_va(entry_va)?;
        if entry.len() < entry_size {
            return None;
        }
        // name string (ptr,len) @ 0; data string (ptr,len) @ 2*ps; hash @ 4*ps.
        let name_ptr = read_uintptr(entry, 0, ps)?;
        let name_len = read_uintptr(entry, p, ps)?;
        let data_ptr = read_uintptr(entry, p.checked_mul(2)?, ps)?;
        let data_len = read_uintptr(entry, p.checked_mul(3)?, ps)?;
        let hash = entry.get(p.checked_mul(4)?..p.checked_mul(4)?.checked_add(16)?)?;
        let hash_is_zero = hash.iter().all(|&b| b == 0);

        let path = read_path(ctx, name_ptr, name_len)?;
        let is_dir = path.ends_with('/');

        let asset_data: &[u8] = if is_dir {
            // Directories: no backing bytes, zero hash.
            if data_ptr != 0 || data_len != 0 || !hash_is_zero {
                return None;
            }
            &[]
        } else {
            // Files: a real truncated-SHA256 hash (non-zero) and resolvable
            // backing bytes.
            if hash_is_zero || data_len == 0 || data_len > MAX_DATA_LEN {
                return None;
            }
            let bytes = ctx.slice_at_va(data_ptr)?;
            bytes.get(..data_len as usize)?
        };

        // Enforce the canonical embed ordering.
        let key = embed_sort_key(path);
        if let Some(prev) = &prev_key
            && *prev >= key
        {
            return None;
        }
        prev_key = Some(key);

        assets.push(EmbeddedAsset {
            path,
            is_dir,
            data: asset_data,
        });
    }
    if assets.is_empty() {
        return None;
    }
    Some(assets)
}

/// The `(dir, base)` sort key `embed` orders its file list by.
///
/// Mirrors `embed.split`: strip a trailing `/`, then split at the last
/// remaining `/` (a missing dir becomes `"."`).
fn embed_sort_key(name: &str) -> (String, String) {
    let n = name.strip_suffix('/').unwrap_or(name);
    match n.rfind('/') {
        Some(i) => (
            n.get(..i).unwrap_or(".").to_string(),
            n.get(i.saturating_add(1)..).unwrap_or("").to_string(),
        ),
        None => (".".to_string(), n.to_string()),
    }
}

/// Resolve and validate a `file.name`: a non-empty, path-shaped UTF-8 string
/// with no control characters.
fn read_path<'a>(ctx: &'a BinaryContext<'a>, name_ptr: u64, name_len: u64) -> Option<&'a str> {
    if name_ptr == 0 || name_len == 0 || name_len > MAX_NAME_LEN {
        return None;
    }
    let bytes = ctx.slice_at_va(name_ptr)?;
    let slice = bytes.get(..name_len as usize)?;
    let s = std::str::from_utf8(slice).ok()?;
    // embed paths are clean, relative, forward-slash virtual paths. Rejecting
    // control chars, leading slashes, backslashes, and spaces filters the bulk
    // of coincidental `[]string` matches a blind scan would otherwise hit.
    if s.is_empty()
        || s.starts_with('/')
        || s.contains('\\')
        || s.chars().any(|c| c.is_control() || c == ' ')
    {
        return None;
    }
    Some(s)
}
