//! Xbox 360 texture format parsing and de-tiling.
//!
//! ## XPR2 Container Layout
//!
//! ```text
//! Offset  Size  Field
//! 0x00    4     Magic: "XPR2"
//! 0x04    4     header_size — pixel data section starts at this file offset (e.g. 0x2800)
//! 0x08    4     data_size   — size of the pixel data section (e.g. 0x8A000)
//! 0x0C    4     num_resources
//! 0x10    16*n  Resource directory: n × 16-byte Xpr2ResourceEntry structs
//!               [type_tag:4][data_offset:4][data_size:4][name_offset:4]
//! …       …     Resource descriptors (TX2D = 52-byte D3DBaseTexture2D structs)
//! 0x2800  …     Pixel data section
//! ```
//!
//! ## GPUTEXTURE_FETCH_CONSTANT (GPUFC)
//!
//! Each TX2D descriptor is 52 bytes. The 6-dword (24-byte) GPUFC starts at
//! descriptor offset +0x18:
//!
//! ```text
//! GPUFC[0] (+0x18): tiled flag at bit 31, pitch at bits[23:8]
//! GPUFC[1] (+0x1C): TextureFormat at bits[5:0], base_address at bits[31:12]
//! GPUFC[2] (+0x20): width-1 at bits[12:0], height-1 at bits[25:13]  (size_2d)
//! GPUFC[3] (+0x24): swizzle, filter
//! GPUFC[4] (+0x28): mip_max at bits[9:6]  →  mip_count = mip_max + 1
//! GPUFC[5] (+0x2C): mip_address, packed_mips, dimension
//! ```
//!
//! ## GPU Tiling
//!
//! Xbox 360 Xenos stores textures in 32×32 texel macro-tiles.  Within each
//! macro-tile the DXT blocks are arranged in Morton (Z-order) order.
//! GPUFC[0] bit 31 = 1 → tiled (de-tiling required); = 0 → linear.
//!
//! ## References
//!
//! - Xenia: `src/xenia/gpu/xenos.h`  (GPUTEXTUREFORMAT enum, GPUFC bitfields)
//! - Xenia: `src/xenia/gpu/texture_util.cc`  (de-tiling algorithm)

use binrw::{BinRead, binread};
use thiserror::Error;

// ── Error type ───────────────────────────────────────────────────────────────

#[derive(Debug, Error)]
pub enum TextureError {
    #[error("Invalid texture header magic: expected {expected:?}, got {got:?}")]
    BadMagic { expected: [u8; 4], got: [u8; 4] },

    #[error("No TX2D texture resource found in XPR2 file")]
    NoTextureFound,

    #[error("Unsupported texture format: 0x{0:02X}")]
    UnsupportedFormat(u8),

    #[error("Buffer too small: need {needed} bytes, have {have}")]
    BufferTooSmall { needed: usize, have: usize },

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Parse error: {0}")]
    Parse(#[from] binrw::Error),
}

// ── Texture formats ───────────────────────────────────────────────────────────

/// GPUTEXTUREFORMAT values from Xenia's `xenos.h`.
///
/// These 6-bit codes live in GPUFC dword_1 bits[5:0] — NOT the old D3DFORMAT
/// codes.  The mapping is:
///   k_8_8_8_8  = 6,  k_DXT1 = 18,  k_DXT2_3 = 19,  k_DXT4_5 = 20,
///   k_DXN = 49,  k_DXT5A = 59
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum X360TextureFormat {
    /// A8R8G8B8 — uncompressed 32 bpp (k_8_8_8_8 = 6)
    A8R8G8B8 = 6,
    /// X8R8G8B8 — uncompressed 32 bpp, no alpha (k_8_8_8_8_AS_16_16_16_16 = 7)
    X8R8G8B8 = 7,
    /// DXT1 / BC1 — 4 bpp, 1-bit alpha (k_DXT1 = 18)
    Dxt1 = 18,
    /// DXT2/3 / BC2 — 8 bpp, 4-bit explicit alpha (k_DXT2_3 = 19)
    Dxt3 = 19,
    /// DXT4/5 / BC3 — 8 bpp, 8-bit interpolated alpha (k_DXT4_5 = 20)
    Dxt5 = 20,
    /// DXN / BC5 / ATI2N — two-channel normal maps (k_DXN = 49)
    Dxn = 49,
    /// DXT5A / BC4 / ATI1N — single alpha channel (k_DXT5A = 59)
    /// Used for gloss, specular, luminance, and reflection maps in Project Sylpheed.
    Dxt5A = 59,
}

impl X360TextureFormat {
    pub fn from_u8(v: u8) -> Option<Self> {
        match v {
            6  => Some(Self::A8R8G8B8),
            7  => Some(Self::X8R8G8B8),
            18 => Some(Self::Dxt1),
            19 => Some(Self::Dxt3),
            20 => Some(Self::Dxt5),
            49 => Some(Self::Dxn),
            59 => Some(Self::Dxt5A),
            _  => None,
        }
    }

    /// Bytes per compressed block (4×4 texel group) or per pixel for uncompressed.
    pub fn bytes_per_block(&self) -> usize {
        match self {
            Self::Dxt1 | Self::Dxt5A               => 8,
            Self::Dxt3 | Self::Dxt5 | Self::Dxn    => 16,
            Self::A8R8G8B8 | Self::X8R8G8B8        => 4,
        }
    }

    /// Is this a BCn block-compressed format?
    pub fn is_block_compressed(&self) -> bool {
        matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn | Self::Dxt5A)
    }

    /// Texels per block side (4 for BCn, 1 for uncompressed).
    pub fn block_size(&self) -> usize {
        if self.is_block_compressed() { 4 } else { 1 }
    }
}

// ── XPR2 container format ─────────────────────────────────────────────────────

/// XPR2 container header (big-endian, 16 bytes total including magic).
#[binread]
#[br(magic = b"XPR2", big)]
#[derive(Debug, Clone)]
pub struct Xpr2Header {
    /// Byte offset where the pixel data section starts (= size of header region).
    /// Example value: 0x2800 = 10240.
    pub header_size: u32,
    /// Size of the pixel data section in bytes.
    /// Example value: 0x8A000 = 565248.
    pub data_size: u32,
    /// Number of 16-byte resource entries in the directory at offset 0x10.
    pub num_resources: u32,
}

/// One 16-byte entry in the XPR2 resource directory (starts at file offset 0x10).
#[binread]
#[br(big)]
#[derive(Debug, Clone)]
pub struct Xpr2ResourceEntry {
    /// ASCII type tag: b"TX2D" for 2D textures, b"XBG7" for geometry, etc.
    pub type_tag: [u8; 4],
    /// Byte offset of this resource's descriptor, relative to directory base 0x10.
    /// Actual file offset = data_offset + 0x10.
    pub data_offset: u32,
    /// Size of the resource descriptor in bytes (e.g. 0x34 = 52 for TX2D).
    pub descriptor_size: u32,
    /// Byte offset of this resource's name string, relative to directory base 0x10.
    pub name_offset: u32,
}

impl Xpr2ResourceEntry {
    pub fn is_texture(&self) -> bool {
        &self.type_tag == b"TX2D"
    }

    /// Cubemap resource (`TXCM`) — 6 faces sharing one GPUFC descriptor.
    pub fn is_cubemap(&self) -> bool {
        &self.type_tag == b"TXCM"
    }
}

// ── Decoded texture ───────────────────────────────────────────────────────────

/// A decoded Xbox 360 texture ready for GPU upload.
///
/// After `from_xpr2()` the `data` field holds the texture in standard linear
/// (row-major) layout.  BCn formats are kept as compressed block data; the GPU
/// decompresses in hardware.
#[derive(Debug, Clone)]
pub struct X360Texture {
    pub width: u32,
    pub height: u32,
    pub format: X360TextureFormat,
    pub mip_levels: u32,
    /// True when the source resource was a cubemap (`TXCM`). `data` then holds
    /// only face 0 (the +X face) decoded as a 2D image — enough for a preview.
    pub is_cubemap: bool,
    /// De-tiled texture data in linear order.
    /// BCn: standard packed block data (DDS layout).
    /// Uncompressed: BGRA8 pixel data.
    pub data: Vec<u8>,
}

/// A decoded Xbox 360 cubemap (`TXCM`) — a world skybox. The 6 faces are each a
/// de-tiled 2D surface in the same layout as [`X360Texture::data`], in D3D9
/// `D3DCUBEMAP_FACES` order.
#[derive(Debug, Clone)]
pub struct Cubemap {
    pub width: u32,
    pub height: u32,
    pub format: X360TextureFormat,
    /// Exactly 6 faces, D3D9 order: +X, -X, +Y, -Y, +Z, -Z.
    pub faces: Vec<Vec<u8>>,
}

impl Cubemap {
    /// The D3D9 cube-face name for slice `i` (0..6).
    pub fn face_label(i: usize) -> &'static str {
        ["+X", "-X", "+Y", "-Y", "+Z", "-Z"].get(i).copied().unwrap_or("?")
    }
}

impl X360Texture {
    /// Parse the first TX2D texture from an XPR2 file's raw bytes.
    ///
    /// Pipeline:
    /// 1. Parse XPR2 header + resource directory
    /// 2. Locate the first TX2D entry
    /// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
    /// 4. De-tile the pixel data (if tiled) → linear layout
    pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
        // XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
        // the Nth texture resource (default 0) for RE/browse validation.
        let want = std::env::var("XPR_RES_INDEX")
            .ok()
            .and_then(|v| v.trim().parse::<usize>().ok())
            .unwrap_or(0);
        Self::from_xpr2_index(bytes, want)
    }

    /// List the names of the texture resources (`TX2D` / `TXCM`) in an XPR2
    /// container, in directory order — the same order [`from_xpr2_index`]
    /// selects by. Non-texture resources (e.g. `XBG7`) are skipped.
    pub fn texture_names(bytes: &[u8]) -> Vec<String> {
        use std::io::Cursor;
        let mut cur = Cursor::new(bytes);
        let Ok(header) = Xpr2Header::read(&mut cur) else {
            return Vec::new();
        };
        let mut names = Vec::new();
        for _ in 0..header.num_resources {
            let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else {
                break;
            };
            if e.is_texture() || e.is_cubemap() {
                const DIR_BASE: usize = 0x10;
                let no = e.name_offset as usize + DIR_BASE;
                let name = bytes
                    .get(no..)
                    .and_then(|s| s.iter().position(|&c| c == 0).map(|p| &s[..p]))
                    .map(|s| String::from_utf8_lossy(s).into_owned())
                    .unwrap_or_default();
                names.push(name);
            }
        }
        names
    }

    /// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order).
    pub fn from_xpr2_index(bytes: &[u8], want: usize) -> Result<Self, TextureError> {
        use std::io::Cursor;
        let mut cur = Cursor::new(bytes);

        // Parse header — validates "XPR2" magic, reads 3 × u32 (total 16 bytes)
        let header = Xpr2Header::read(&mut cur)?;

        // Resource directory begins immediately after the 16-byte header (offset 0x10)
        let mut entries = Vec::new();
        for _ in 0..header.num_resources {
            entries.push(Xpr2ResourceEntry::read(&mut cur)?);
        }

        // Select a texture resource. TX2D = 2D texture; TXCM = cubemap
        // (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
        // pixel section holds 6 faces. For a preview we decode face 0.
        let tex_entry = entries.iter()
            .filter(|e| e.is_texture() || e.is_cubemap())
            .nth(want)
            .ok_or(TextureError::NoTextureFound)?;
        let is_cubemap = tex_entry.is_cubemap();

        // The descriptor is at file offset = data_offset + 0x10 (directory base)
        const DIR_BASE: usize = 0x10;
        let desc_file_offset = tex_entry.data_offset as usize + DIR_BASE;

        // GPUFC is a 6-dword (24-byte) block at descriptor offset +0x18
        let gpufc_base = desc_file_offset + 0x18;
        if bytes.len() < gpufc_base + 6 * 4 {
            return Err(TextureError::BufferTooSmall {
                needed: gpufc_base + 24,
                have: bytes.len(),
            });
        }

        // Read one big-endian u32 at the given file offset
        let be_u32 = |offset: usize| -> u32 {
            u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap())
        };

        let gpufc0 = be_u32(gpufc_base);          // +0x18
        let gpufc1 = be_u32(gpufc_base + 0x04);   // +0x1C
        let gpufc2 = be_u32(gpufc_base + 0x08);   // +0x20
        let gpufc4 = be_u32(gpufc_base + 0x10);   // +0x28

        // GPUFC[1] bits[5:0] = GPUTEXTUREFORMAT
        let fmt_code = (gpufc1 & 0x3F) as u8;
        let format = X360TextureFormat::from_u8(fmt_code)
            .ok_or(TextureError::UnsupportedFormat(fmt_code))?;

        // GPUFC[1] bits[7:6] = endianness. X360 stores texture words byte-
        // swapped; without undoing this, BCn endpoints/indices and ARGB channels
        // decode to noise. (fetch-constant `endianness`, xenos.h Endian.)
        let endianness = ((gpufc1 >> 6) & 0x3) as u8;

        // GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
        let base_address = (gpufc1 & 0xFFFFF000) as usize;

        // GPUFC[2] / size_2d: width-1 in bits[12:0], height-1 in bits[25:13]
        let width  = (gpufc2 & 0x1FFF) + 1;
        let height = ((gpufc2 >> 13) & 0x1FFF) + 1;

        // GPUFC[4]: mip_max in bits[9:6]; mip_count = mip_max + 1
        let mip_count = ((gpufc4 >> 6) & 0xF) + 1;

        // GPUFC[0] bit 31 = 1 → tiled memory layout (requires de-tiling)
        let is_tiled = (gpufc0 >> 31) != 0;

        // Pixel data for this texture starts at: header_size + base_address
        let data_start = header.header_size as usize + base_address;
        if bytes.len() <= data_start {
            return Err(TextureError::BufferTooSmall {
                needed: data_start + 1,
                have: bytes.len(),
            });
        }

        // De-tile + endian-correct the single (face-0) surface.
        let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;

        Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
    }

    /// Decode all 6 faces of a cubemap (`TXCM`) resource, or `Ok(None)` if the
    /// selected resource is an ordinary 2D texture.
    ///
    /// X360 cubemaps store the 6 faces back-to-back in the pixel section, each a
    /// full independently-tiled 2D surface whose stride is the tiled surface size
    /// rounded up to a 4 KiB subresource boundary (`kTextureSubresourceAlignmentBytes`).
    /// Faces are in D3D9 `D3DCUBEMAP_FACES` order: +X, -X, +Y, -Y, +Z, -Z.
    /// (Derived from xenia `texture_util.cc::GetGuestTextureLayout`; verified on
    /// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
    pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
        use std::io::Cursor;
        let mut cur = Cursor::new(bytes);
        let header = Xpr2Header::read(&mut cur)?;
        let mut entries = Vec::new();
        for _ in 0..header.num_resources {
            entries.push(Xpr2ResourceEntry::read(&mut cur)?);
        }
        let want = std::env::var("XPR_RES_INDEX")
            .ok()
            .and_then(|v| v.trim().parse::<usize>().ok())
            .unwrap_or(0);
        let tex_entry = entries
            .iter()
            .filter(|e| e.is_texture() || e.is_cubemap())
            .nth(want)
            .ok_or(TextureError::NoTextureFound)?;
        if !tex_entry.is_cubemap() {
            return Ok(None); // ordinary 2D texture — use `from_xpr2`
        }

        const DIR_BASE: usize = 0x10;
        let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
        if bytes.len() < gpufc_base + 6 * 4 {
            return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
        }
        let be_u32 = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
        let gpufc0 = be_u32(gpufc_base);
        let gpufc1 = be_u32(gpufc_base + 0x04);
        let gpufc2 = be_u32(gpufc_base + 0x08);

        let fmt_code = (gpufc1 & 0x3F) as u8;
        let format = X360TextureFormat::from_u8(fmt_code)
            .ok_or(TextureError::UnsupportedFormat(fmt_code))?;
        let endianness = ((gpufc1 >> 6) & 0x3) as u8;
        let base_address = (gpufc1 & 0xFFFFF000) as usize;
        let width = (gpufc2 & 0x1FFF) + 1;
        let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
        let is_tiled = (gpufc0 >> 31) != 0;

        let data_start = header.header_size as usize + base_address;
        let stride = tiled_face_stride(width, height, format);

        let mut faces = Vec::with_capacity(6);
        for f in 0..6 {
            let start = data_start + f * stride;
            let raw = bytes
                .get(start..)
                .ok_or(TextureError::BufferTooSmall { needed: start + 1, have: bytes.len() })?;
            faces.push(decode_surface(raw, width, height, format, is_tiled, endianness)?);
        }
        Ok(Some(Cubemap { width, height, format, faces }))
    }

    /// Parse a texture from already-known parameters + raw tiled data.
    ///
    /// Use when you have reverse-engineered a container and extracted the
    /// raw tiled bytes yourself.
    pub fn from_raw_tiled(
        tiled_data: &[u8],
        width: u32,
        height: u32,
        format: X360TextureFormat,
    ) -> Result<Self, TextureError> {
        let linear_data = detile(tiled_data, width, height, format)?;
        Ok(X360Texture { width, height, format, mip_levels: 1, is_cubemap: false, data: linear_data })
    }
}

/// Apply the Xenos texture endian swap in place, as byte permutations over the
/// data. Xbox 360 stores texture words big-endian; this converts them to the
/// PC-standard little-endian layout that BCn decoders and wgpu expect.
/// `endianness` is the fetch-constant `dword_1` bits[7:6]:
/// 0 = none, 1 = k8in16, 2 = k8in32, 3 = k16in32 (xenia `xenos.h` `Endian`).
pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
    match endianness {
        1 => {
            // k8in16 — swap the two bytes of each 16-bit half.
            for c in data.chunks_exact_mut(2) {
                c.swap(0, 1);
            }
        }
        2 => {
            // k8in32 — reverse each 32-bit word.
            for c in data.chunks_exact_mut(4) {
                c.reverse();
            }
        }
        3 => {
            // k16in32 — swap the two 16-bit halves of each 32-bit word.
            for c in data.chunks_exact_mut(4) {
                c.swap(0, 2);
                c.swap(1, 3);
            }
        }
        _ => {} // kNone
    }
}

/// De-tile (if tiled) + endian-correct one texture surface into linear PC
/// layout. Shared by `from_xpr2` (face 0) and `cube_faces_from_xpr2` (6 faces).
/// The `XPR_NO_DETILE` / `XPR_NO_ENDIAN` / `XPR_FORCE_ENDIAN` / `XPR_NO_BC_DWORD_SWAP`
/// debug knobs are honoured here so both paths behave identically.
fn decode_surface(
    raw_data: &[u8],
    width: u32,
    height: u32,
    format: X360TextureFormat,
    is_tiled: bool,
    endianness: u8,
) -> Result<Vec<u8>, TextureError> {
    let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
    let mut linear_data = if is_tiled && !force_linear {
        detile(raw_data, width, height, format)?
    } else {
        // Linear layout — copy only the mip-0 slice.
        let block_size = format.block_size() as u32;
        let bw = ((width + block_size - 1) / block_size).max(1);
        let bh = ((height + block_size - 1) / block_size).max(1);
        let needed = bw as usize * bh as usize * format.bytes_per_block();
        if raw_data.len() < needed {
            return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
        }
        raw_data[..needed].to_vec()
    };

    // Undo the X360 word byte-swap so block/pixel data is PC little-endian.
    let endianness = std::env::var("XPR_FORCE_ENDIAN")
        .ok()
        .and_then(|v| v.trim().parse::<u8>().ok())
        .unwrap_or(endianness);
    if std::env::var("XPR_NO_ENDIAN").is_err() {
        apply_endian_swap(&mut linear_data, endianness);
    }

    // BC1 colour-block dword swap (see the long note where this was discovered):
    // colour endpoints live in the high dword on X360; format-targeted so BC4/BC5
    // (byte-indexed alpha/normal blocks) are left as endian-only.
    if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
        match format {
            X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
            X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
                for block in linear_data.chunks_exact_mut(16) {
                    swap_bc_block_dwords(&mut block[8..16]);
                }
            }
            _ => {}
        }
    }
    Ok(linear_data)
}

/// Byte stride between consecutive cubemap faces: the tiled surface size
/// (`pitch_aligned × height_aligned × bpb`, both padded to 32-block macro tiles)
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
    let bs = format.block_size() as u32;
    let bw = ((width + bs - 1) / bs).max(1);
    let bh = ((height + bs - 1) / bs).max(1);
    let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
    let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
    let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
    (surface + 0xFFF) & !0xFFF
}

/// Swap the two 32-bit dwords within each 64-bit unit of `data`, in place.
///
/// Xbox 360 stores the BC1 *colour* block with its two 32-bit words in the
/// opposite order to the PC/DDS layout, putting the colour endpoints ahead of
/// the indices. Apply to a BC1 buffer (or the colour half of a BC2/BC3 block)
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
/// 8-byte group are left untouched.
pub fn swap_bc_block_dwords(data: &mut [u8]) {
    for unit in data.chunks_exact_mut(8) {
        // [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
        let (lo, hi) = unit.split_at_mut(4);
        lo.swap_with_slice(hi);
    }
}

// ── Core de-tiling algorithm ──────────────────────────────────────────────────

/// Macro-tile side in blocks (Xenos tiles are 32×32 *blocks*, where a "block"
/// is one BCn 4×4 group or one uncompressed texel). `texture_address.h`
/// `kTextureTileWidthHeight` / `kMacroTileWidth`.
const MACRO_TILE_BLOCKS: u32 = 32;
/// Storage pitch/height alignment, in blocks (`kStoragePitchHeightAlignmentBlocks`).
const STORAGE_ALIGN_BLOCKS: u32 = 32;

#[inline]
fn align_up(v: u32, a: u32) -> u32 {
    (v + a - 1) & !(a - 1)
}

/// Byte offset of block (x, y) within an Xbox 360 2D tiled surface.
///
/// Faithful port of xenia-canary `texture_address.h::Tiled2D` + `TiledCombine`
/// (documented Xenos hardware tiling — bank/pipe/macro-tile addressing, NOT a
/// plain Morton curve). `x`/`y` and `pitch_aligned` are in block units;
/// `bpb_log2` is log2(bytes-per-block). Returns a byte offset.
#[inline]
fn tiled_2d_offset(x: i32, y: i32, pitch_aligned: u32, bpb_log2: u32) -> i32 {
    let outer_blocks = ((y >> 5) * (pitch_aligned >> 5) as i32 + (x >> 5)) << 6;
    let inner_blocks = (((y >> 1) & 0b111) << 3) | (x & 0b111);
    let outer_inner_bytes = (outer_blocks | inner_blocks) << bpb_log2;
    let bank = (y >> 4) & 0b1;
    let pipe = ((x >> 3) & 0b11) ^ (((y >> 3) & 0b1) << 1);
    let y_lsb = y & 1;
    // TiledCombine: splice bank/pipe/y_lsb bits into the byte address.
    ((y_lsb << 4) | (pipe << 6) | (bank << 11))
        | (outer_inner_bytes & 0b1111)
        | (((outer_inner_bytes >> 4) & 0b1) << 5)
        | (((outer_inner_bytes >> 5) & 0b111) << 8)
        | (outer_inner_bytes >> 8 << 12)
}

/// De-tile an Xbox 360 GPU texture (mip-0) from tiled to linear (row-major)
/// layout, using the exact Xenos address formula.
///
/// `src` must hold the tiled mip-0 surface (its storage pitch/height are
/// rounded up to 32 blocks, so it may be larger than the visible image). The
/// returned buffer is tightly packed linear block data (DDS layout for BCn).
pub fn detile(
    src: &[u8],
    width: u32,
    height: u32,
    format: X360TextureFormat,
) -> Result<Vec<u8>, TextureError> {
    let block_size = format.block_size() as u32;
    let bpb = format.bytes_per_block();
    let bpb_log2 = (bpb as u32).trailing_zeros();

    // Visible dimensions in blocks, and the padded storage pitch/height.
    let blocks_wide = ((width + block_size - 1) / block_size).max(1);
    let blocks_tall = ((height + block_size - 1) / block_size).max(1);
    let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
    let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);

    // The tiled surface occupies pitch_aligned × height_aligned blocks.
    let src_needed = pitch_aligned as usize * height_aligned as usize * bpb;
    if src.len() < src_needed {
        return Err(TextureError::BufferTooSmall { needed: src_needed, have: src.len() });
    }

    let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
    let mut dst = vec![0u8; dst_len];

    for by in 0..blocks_tall {
        for bx in 0..blocks_wide {
            let src_offset = tiled_2d_offset(bx as i32, by as i32, pitch_aligned, bpb_log2) as usize;
            let dst_offset = (by * blocks_wide + bx) as usize * bpb;
            if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
                dst[dst_offset..dst_offset + bpb]
                    .copy_from_slice(&src[src_offset..src_offset + bpb]);
            }
        }
    }

    Ok(dst)
}

/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
///
/// Morton encoding interleaves bits:  index = …y2 x2 y1 x1 y0 x0
#[inline]
pub fn morton_decode(index: u32) -> (u32, u32) {
    let x = compact_bits(index);
    let y = compact_bits(index >> 1);
    (x, y)
}

/// Extract every other bit and pack them into the low bits.
/// Used by `morton_decode` to de-interleave X and Y.
#[inline]
fn compact_bits(mut x: u32) -> u32 {
    x &= 0x5555_5555;                   // keep even-position bits
    x = (x ^ (x >> 1))  & 0x3333_3333;
    x = (x ^ (x >> 2))  & 0x0f0f_0f0f;
    x = (x ^ (x >> 4))  & 0x00ff_00ff;
    x = (x ^ (x >> 8))  & 0x0000_ffff;
    x
}

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn morton_decode_corners() {
        assert_eq!(morton_decode(0), (0, 0));
        assert_eq!(morton_decode(1), (1, 0)); // bit 0 → x
        assert_eq!(morton_decode(2), (0, 1)); // bit 1 → y
        assert_eq!(morton_decode(3), (1, 1));
    }

    #[test]
    fn tiled_offset_origin_is_zero() {
        // Block (0,0) always maps to byte offset 0 for any pitch / bpb.
        assert_eq!(tiled_2d_offset(0, 0, 32, 3), 0);
        assert_eq!(tiled_2d_offset(0, 0, 64, 4), 0);
    }

    #[test]
    fn detile_single_block_reads_offset_zero() {
        // A 4×4 DXT1 texture = 1 visible block, but Xenos pads the tiled
        // surface to 32×32 blocks (8192 bytes). Block (0,0) sits at offset 0,
        // so the de-tiled output equals the first 8 source bytes.
        let mut src = vec![0u8; 32 * 32 * 8];
        src[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
        let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
        assert_eq!(result, &src[..8]);
    }

    #[test]
    fn swap_bc_dwords_swaps_each_64bit_half() {
        // One 8-byte BC1 block: X360 stores it as [indices][endpoints]; the swap
        // must move the endpoint dword to the front so BC decoders find it.
        let mut one = vec![0, 1, 2, 3, 4, 5, 6, 7];
        swap_bc_block_dwords(&mut one);
        assert_eq!(one, vec![4, 5, 6, 7, 0, 1, 2, 3]);

        // A 16-byte BC3 block = two independent 8-byte halves; each is swapped.
        let mut two: Vec<u8> = (0..16).collect();
        swap_bc_block_dwords(&mut two);
        assert_eq!(
            two,
            vec![4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11]
        );

        // Applying it twice is the identity (it's its own inverse).
        let mut back = two.clone();
        swap_bc_block_dwords(&mut back);
        assert_eq!(back, (0..16).collect::<Vec<u8>>());
    }

    #[test]
    fn cubemap_face_stride_and_labels() {
        // Acheron: 1024×1024 A8R8G8B8 → 1024*1024*4 = 4 MiB, already 4 KiB-aligned.
        // Verified against the real file: data_size == 6 × 0x400000.
        assert_eq!(
            tiled_face_stride(1024, 1024, X360TextureFormat::A8R8G8B8),
            0x400000
        );
        // A tiny surface still occupies a full 32×32-block tile, 4 KiB-aligned.
        assert_eq!(tiled_face_stride(4, 4, X360TextureFormat::A8R8G8B8), 0x1000);
        assert_eq!(Cubemap::face_label(0), "+X");
        assert_eq!(Cubemap::face_label(2), "+Y");
        assert_eq!(Cubemap::face_label(5), "-Z");
    }

    #[test]
    fn x360_format_bytes_per_block() {
        assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(),  8);
        assert_eq!(X360TextureFormat::Dxt5A.bytes_per_block(), 8);
        assert_eq!(X360TextureFormat::Dxt5.bytes_per_block(), 16);
        assert_eq!(X360TextureFormat::A8R8G8B8.bytes_per_block(), 4);
    }

    #[test]
    fn format_from_u8_roundtrip() {
        for code in [6u8, 7, 18, 19, 20, 49, 59] {
            assert!(X360TextureFormat::from_u8(code).is_some(), "missing format {code}");
        }
        assert!(X360TextureFormat::from_u8(0x52).is_none(), "old D3DFORMAT 0x52 must not match");
    }
}
