var film2d = {}
var backend = null

film2d.set_backend = function(b) {
  backend = b
}

var next_id = 1
var registry = {}       // id -> drawable
var group_index = {}    // group_name -> [id, id, ...]
var plane_index = {}    // plane_name -> [id, id, ...]

// Resolve sprite dimensions and UV based on fit mode
// fit: 'fill' | 'contain' | 'cover' | 'none'
// - fill: stretch to exactly match box (distort)
// - contain: fit inside box, preserve aspect (letterbox)
// - cover: fill box, preserve aspect (crop)
// - none: use native pixel size (or pixels_per_tile driven)
function _resolve_sprite_fit(sprite) {
  if (!backend || !backend.get_texture_info) return sprite
  
  var img = sprite.texture || sprite.image
  if (!img) return sprite
  
  var tex_info = backend.get_texture_info(img)
  if (!tex_info || !tex_info.width || !tex_info.height) return sprite
  
  var tex_w = tex_info.width
  var tex_h = tex_info.height
  var tex_aspect = tex_w / tex_h
  
  var target_w = sprite.width
  var target_h = sprite.height
  var fit = sprite.fit || 'none'
  
  // If one dimension is null, derive from aspect ratio
  if (target_w == null && target_h != null) {
    target_w = target_h * tex_aspect
    sprite.width = target_w
    sprite.height = target_h
    return sprite
  }
  if (target_h == null && target_w != null) {
    target_h = target_w / tex_aspect
    sprite.width = target_w
    sprite.height = target_h
    return sprite
  }
  
  // Both null - use native size (1 pixel = 1 unit, or could use pixels_per_tile)
  if (target_w == null && target_h == null) {
    sprite.width = tex_w
    sprite.height = tex_h
    return sprite
  }
  
  // Both dimensions specified - apply fit mode
  var box_aspect = target_w / target_h
  
  if (fit == 'fill') {
    // Stretch to fill - no changes needed, just use target dimensions
    sprite.width = target_w
    sprite.height = target_h
    return sprite
  }
  
  var scale = null
  if (fit == 'contain') {
    // Fit inside box, preserve aspect (letterbox)
    if (tex_aspect > box_aspect) {
      // Image wider than box - constrain by width
      scale = target_w / tex_w
    } else {
      // Image taller than box - constrain by height
      scale = target_h / tex_h
    }
    sprite.width = tex_w * scale
    sprite.height = tex_h * scale
    return sprite
  }
  
  var cover_scale = null
  var fit_ax = null
  var fit_ay = null
  var scale_w = null
  var scale_h = null
  var visible_w = null
  var visible_h = null
  var uv_w = null
  var uv_h = null
  var uv_x = null
  var uv_y = null
  if (fit == 'cover') {
    // Fill box, preserve aspect (crop via UV)
    fit_ax = sprite.fit_anchor_x != null ? sprite.fit_anchor_x : 0.5
    fit_ay = sprite.fit_anchor_y != null ? sprite.fit_anchor_y : 0.5

    scale_w = target_w / tex_w
    scale_h = target_h / tex_h
    cover_scale = max(scale_w, scale_h)
    
    // Compute visible portion of texture in UV space
    visible_w = target_w / cover_scale
    visible_h = target_h / cover_scale

    // UV rect (0-1 space)
    uv_w = visible_w / tex_w
    uv_h = visible_h / tex_h
    uv_x = (1 - uv_w) * fit_ax
    uv_y = (1 - uv_h) * fit_ay
    
    sprite.width = target_w
    sprite.height = target_h
    sprite.uv_rect = {x: uv_x, y: uv_y, width: uv_w, height: uv_h}
    return sprite
  }
  
  // fit == 'none' - use native size
  sprite.width = tex_w
  sprite.height = tex_h
  return sprite
}

film2d.register = function(drawable) {
  var id = text(next_id++)
  drawable._id = id
  registry[id] = drawable
  
  // Index by plane
  var plane = drawable.plane || 'default'
  if (!plane_index[plane]) plane_index[plane] = []
    push(plane_index[plane], id)
  
  // Index by groups (effect routing only)
  var groups = drawable.groups || []
  var i = 0
  var g = null
  for (i = 0; i < length(groups); i++) {
    g = groups[i]
    if (!group_index[g]) group_index[g] = []
    push(group_index[g], id)
  }

  return id
}

film2d.unregister = function(id) {
  var id_str = text(id)
  var drawable = registry[id_str]
  if (!drawable) return

  // Remove from plane index
  var plane = drawable.plane || 'default'
  var idx = null
  if (plane_index[plane]) {
    idx = find(plane_index[plane], id_str)
    if (idx != null)
      plane_index[plane] = array(array(plane_index[plane], 0, idx), array(plane_index[plane], idx+1))
  }

  // Remove from group indices
  var groups = drawable.groups || []
  var i = 0
  var g = null
  for (i = 0; i < length(groups); i++) {
    g = groups[i]
    if (group_index[g]) {
      idx = find(group_index[g], id_str)
      if (idx != null)
        group_index[g] = array(array(group_index[g], 0, idx), array(group_index[g], idx+1))
    }
  }
  
  delete registry[id_str]
}

film2d.index_group = function(id, group) {
  if (!group_index[group]) group_index[group] = []
  if (search(group_index[group], text(id)) == null)
    push(group_index[group], text(id))
}

film2d.unindex_group = function(id, group) {
  if (!group_index[group]) return
  var idx = search(group_index[group], text(id))
  if (idx != null)
    group_index[group] = array(array(group_index[group], 0, idx), array(group_index[group], idx+1))
}

film2d.reindex = function(id, old_groups, new_groups) {
  var i = 0
  for (i = 0; i < length(old_groups); i++)
    film2d.unindex_group(id, old_groups[i])
  for (i = 0; i < length(new_groups); i++)
    film2d.index_group(id, new_groups[i])
}

film2d.get = function(id) {
  return registry[text(id)]
}

// Query by plane and/or group - returns array of drawables
film2d.query = function(selector) {
  var result = []
  var ids = null
  var i = 0
  var d = null
  var groups = null
  var seen = null
  var g = 0

  // Query by plane (primary selection)
  if (selector.plane) {
    ids = plane_index[selector.plane] || []
    for (i = 0; i < length(ids); i++) {
      d = registry[ids[i]]
      if (d && d.visible != false) {
        // If also filtering by group, check membership
        if (selector.group) {
          groups = d.groups || []
          if (search(groups, selector.group) != null) push(result, d)
        } else {
          push(result, d)
        }
      }
    }
    return result
  }

  // Query by group only (for effect routing)
  if (selector.group) {
    ids = group_index[selector.group] || []
    for (i = 0; i < length(ids); i++) {
      d = registry[ids[i]]
      if (d && d.visible != false) push(result, d)
    }
    return result
  }

  if (selector.groups) {
    seen = {}
    for (g = 0; g < length(selector.groups); g++) {
      ids = group_index[selector.groups[g]] || []
      for (i = 0; i < length(ids); i++) {
        if (!seen[ids[i]]) {
          seen[ids[i]] = true
          d = registry[ids[i]]
          if (d && d.visible != false) push(result, d)
        }
      }
    }
    return result
  }

  // All drawables
  var draws = array(registry, id => registry[id])
  result = array(result, filter(draws, d => d.visible != false))
  return result
}

// Get all groups a drawable belongs to
film2d.get_groups = function(id) {
  var d = registry[text(id)]
  return d ? (d.groups || []) : []
}

// List all known groups
film2d.all_groups = function() {
  var groups = []
  arrfor(array(group_index), g => {
    if (length(group_index[g]) > 0) push(groups, g)
  })
  return groups
}

// Render function - takes drawables directly, no tree traversal
film2d.render = function(params, render_backend) {
  backend = render_backend

  var drawables = params.drawables || []
  var camera = params.camera
  var target = params.target
  var target_size = params.target_size
  var clear_color = params.clear
  var layer_sort = params.layer_sort || {}  // layer(text) -> "y" or "explicit"

  if (length(drawables) == 0) return { commands: [] }

  function _y_sort_key(d) {
    if (!d || !d.pos) return 0
    var y = d.pos.y || 0
    var h = d.height || 0
    var ay = d.anchor_y
    if (ay == null) ay = 0.5
    return y + h * (1 - ay)   // "pos at anchor" -> "feet y"
  }

  // Deterministic "explicit" order anchor (keep if you want _id tie-break behavior)
  drawables = sort(drawables, "_id")

  // Bucket drawables by layer
  var buckets = {}
  var i = 0
  var d = null
  var layer_key = null
  var b = null
  for (i = 0; i < length(drawables); i++) {
    d = drawables[i]
    layer_key = text(d.layer)
    b = buckets[layer_key]
    if (!b) {
      b = []
      buckets[layer_key] = b
    }
    push(b, d)
  }

  // Sort layers numerically (keys are text)
  var layers = array(buckets)                       // text keys
  var layer_nums = array(layers, k => number(k))
  layers = sort(layers, layer_nums)

  // Merge buckets, y-sorting buckets that request it
  var y_down = camera && camera.y_down == true
  var sorted_drawables = []
  var li = 0
  var mode = null
  var keys = null
  var j = 0

  for (li = 0; li < length(layers); li++) {
    layer_key = layers[li]
    b = buckets[layer_key]

    mode = layer_sort[layer_key] || "explicit"
    if (mode == "y") {
      keys = array(b, d => _y_sort_key(d))
      b = sort(b, keys)             // ascending feet-y
      if (!y_down) b = reverse(b)   // y_up => smaller y draws later => reverse
    }

    for (j = 0; j < length(b); j++) push(sorted_drawables, b[j])
  }

  drawables = sorted_drawables

  var commands = []
  push(commands, { cmd: "begin_render", target: target, clear: clear_color, target_size: target_size })
  push(commands, { cmd: "set_camera", camera: camera })

  var batches = _batch_drawables(drawables)
  var batch = null

  for (i = 0; i < length(batches); i++) {
    batch = batches[i]
    if (batch.type == "sprite_batch")
      push(commands, { cmd: "draw_batch", batch_type: "sprites", geometry: { sprites: batch.sprites }, texture: batch.texture, material: batch.material })
    else if (batch.type == "mesh2d_batch")
      push(commands, { cmd: "draw_mesh2d", meshes: batch.meshes, texture: batch.texture, material: batch.material })
    else if (batch.type == "text")
      push(commands, { cmd: "draw_text", drawable: batch.drawable })
    else if (batch.type == "texture_ref")
      push(commands, { cmd: "draw_texture_ref", drawable: batch.drawable })
    else if (batch.type == "shape")
      push(commands, { cmd: "draw_shape", drawable: batch.drawable })
  }

  push(commands, { cmd: "end_render" })
  return { commands: commands }
}

function _batch_drawables(drawables) {
  var batches = []
  var current = null
  var default_mat = {blend: 'alpha', sampler: 'nearest'}
  var i = 0
  var d = null
  var tex = null
  var mat = null
  var particles = null
  var emitter_opacity = 0
  var emitter_tint = null
  var p = 0
  var part = null
  var pc = null
  var sprite = null
  var tiles = null
  var tile_w = 0
  var tile_h = 0
  var off_x = 0
  var off_y = 0
  var tilemap_opacity = 0
  var tilemap_tint = null
  var x = 0
  var y = 0
  var img = null
  var wx = 0
  var wy = 0

  for (i = 0; i < length(drawables); i++) {
    d = drawables[i]

    if (d.type == 'sprite') {
      // Resolve fit mode (computes final width/height/uv_rect)
      _resolve_sprite_fit(d)

      tex = d.texture || d.image
      mat = d.material || {blend: 'alpha', sampler: d.filter || 'nearest'}

      if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
        push(current.sprites, d)
      } else {
        if (current) push(batches, current)
        current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [d]}
      }
    } else if (d.type == 'particles') {
      // Convert particles to sprites
      tex = d.texture || d.image
      mat = d.material || default_mat
      particles = d.particles || []
      emitter_opacity = d.opacity != null ? d.opacity : 1
      emitter_tint = d.tint || {r: 1, g: 1, b: 1, a: 1}

      for (p = 0; p < length(particles); p++) {
        part = particles[p]
        pc = part.color || {r: 1, g: 1, b: 1, a: 1}
        sprite = {
          type: 'sprite',
          pos: part.pos,
          width: (d.width || 16) * (part.scale || 1),
          height: (d.height || 16) * (part.scale || 1),
          anchor_x: 0.5,
          anchor_y: 0.5,
          color: pc,
          opacity: emitter_opacity,
          tint: emitter_tint
        }

        if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
          push(current.sprites, sprite)
        } else {
          if (current) push(batches, current)
          current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [sprite]}
        }
      }
    } else if (d.type == 'tilemap') {
      // Expand tilemap to sprites
      tiles = d.tiles || []
      tile_w = d.tile_width || 1
      tile_h = d.tile_height || 1
      off_x = d.offset_x || 0
      off_y = d.offset_y || 0
      tilemap_opacity = d.opacity != null ? d.opacity : 1
      tilemap_tint = d.tint || {r: 1, g: 1, b: 1, a: 1}

      for (x = 0; x < length(tiles); x++) {
        if (!tiles[x]) continue
        for (y = 0; y < length(tiles[x]); y++) {
          img = tiles[x][y]
          if (!img) continue

          wx = (x + off_x) * tile_w
          wy = (y + off_y) * tile_h

          // Center anchor for sprite
          sprite = {
            type: 'sprite',
            image: img,
            pos: {x: wx + tile_w/2, y: wy + tile_h/2},
            width: tile_w,
            height: tile_h,
            anchor_x: 0.5,
            anchor_y: 0.5,
            color: {r: 1, g: 1, b: 1, a: 1},
            opacity: tilemap_opacity,
            tint: tilemap_tint
          }

          // Batching
          tex = img
          mat = default_mat
          if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
            push(current.sprites, sprite)
          } else {
            if (current) push(batches, current)
            current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [sprite]}
          }
        }
      }
    } else if (d.type == 'mesh2d') {
      // Mesh2d drawables - arbitrary triangle meshes (for lines, ropes, etc)
      tex = d.texture || d.image
      mat = d.material || {blend: d.blend || 'alpha', sampler: d.filter || 'linear'}
      
      if (current && current.type == 'mesh2d_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
        push(current.meshes, d)
      } else {
        if (current) push(batches, current)
        current = {type: 'mesh2d_batch', texture: tex, material: mat, meshes: [d]}
      }
    } else if (d.type == 'shape') {
      // Shapes are rendered individually (each has unique SDF params)
      if (current) {
        push(batches, current)
        current = null
      }
      push(batches, {type: 'shape', drawable: d})
    } else {
      if (current) {
        push(batches, current)
        current = null
      }
      push(batches, {type: d.type, drawable: d})
    }
  }
  
  if (current) push(batches, current)
  return batches
}

function _mat_eq(a, b) {
  if (!a || !b) return a == b
  return a.blend == b.blend && a.sampler == b.sampler
}

return film2d