// Layer models: VectorLayer, AudioTrack, and VideoLayer classes

import { context, config, pointerList } from '../state.js';
import { Frame, AnimationData, Keyframe, tempFrame } from './animation.js';
import { Widget } from '../widgets.js';
import { Bezier } from '../bezier.js';
import {
  lerp,
  lerpColor,
  getKeyframesSurrounding,
  growBoundingBox,
  floodFillRegion,
  getShapeAtPoint,
  generateWaveform
} from '../utils.js';
import { frameReceiver } from '../frame-receiver.js';

// External libraries (globals)
const Tone = window.Tone;

// Tauri API
const { invoke, Channel } = window.__TAURI__.core;

// Helper function for UUID generation
function uuidv4() {
  return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
    (
      +c ^
      (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
    ).toString(16),
  );
}

// Forward declarations for circular dependencies
// These will be set by main.js after all modules are loaded
let GraphicsObject = null;
let Shape = null;
let TempShape = null;
let updateUI = null;
let updateMenu = null;
let updateLayers = null;
let vectorDist = null;
let minSegmentSize = null;
let debugQuadtree = null;
let debugCurves = null;
let debugPoints = null;
let debugPaintbucket = null;
let d3 = null;
let actions = null;

// Initialize function to be called from main.js
export function initializeLayerDependencies(deps) {
  GraphicsObject = deps.GraphicsObject;
  Shape = deps.Shape;
  TempShape = deps.TempShape;
  updateUI = deps.updateUI;
  updateMenu = deps.updateMenu;
  updateLayers = deps.updateLayers;
  vectorDist = deps.vectorDist;
  minSegmentSize = deps.minSegmentSize;
  debugQuadtree = deps.debugQuadtree;
  debugCurves = deps.debugCurves;
  debugPoints = deps.debugPoints;
  debugPaintbucket = deps.debugPaintbucket;
  d3 = deps.d3;
  actions = deps.actions;
}

class VectorLayer extends Widget {
  constructor(uuid, parentObject = null) {
    super(0,0)
    if (!uuid) {
      this.idx = uuidv4();
    } else {
      this.idx = uuid;
    }
    this.name = "VectorLayer";
    // LEGACY: Keep frames array for backwards compatibility during migration
    this.frames = [new Frame("keyframe", this.idx + "-F1")];
    this.animationData = new AnimationData(this);
    this.parentObject = parentObject; // Reference to parent GraphicsObject (for nested objects)
    // this.frameNum = 0;
    this.visible = true;
    this.audible = true;
    pointerList[this.idx] = this;
    this.children = []
    this.shapes = []
  }
  static fromJSON(json, parentObject = null) {
    const layer = new VectorLayer(json.idx, parentObject);
    for (let i in json.children) {
      const child = json.children[i];
      const childObject = GraphicsObject.fromJSON(child);
      childObject.parentLayer = layer;
      layer.children.push(childObject);
    }
    layer.name = json.name;

    // Load animation data if present (new system)
    if (json.animationData) {
      layer.animationData = AnimationData.fromJSON(json.animationData, layer);
    }

    // Load shapes if present
    if (json.shapes) {
      layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer));
    }

    // Load frames if present (old system - for backwards compatibility)
    if (json.frames) {
      layer.frames = [];
      for (let i in json.frames) {
        const frame = json.frames[i];
        if (!frame) {
          layer.frames.push(undefined)
          continue;
        }
        if (frame.frameType=="keyframe") {
          layer.frames.push(Frame.fromJSON(frame));
        } else {
          if (layer.frames[layer.frames.length-1]) {
            if (frame.frameType == "motion") {
              layer.frames[layer.frames.length-1].keyTypes.add("motion")
            } else if (frame.frameType == "shape") {
              layer.frames[layer.frames.length-1].keyTypes.add("shape")
            }
          }
          layer.frames.push(undefined)
        }
      }
    }

    layer.visible = json.visible;
    layer.audible = json.audible;

    return layer;
  }
  toJSON(randomizeUuid = false) {
    const json = {};
    json.type = "VectorLayer";
    if (randomizeUuid) {
      json.idx = uuidv4();
      json.name = this.name + " copy";
    } else {
      json.idx = this.idx;
      json.name = this.name;
    }
    json.children = [];
    let idMap = {}
    for (let child of this.children) {
      let childJson = child.toJSON(randomizeUuid)
      idMap[child.idx] = childJson.idx
      json.children.push(childJson);
    }

    // Serialize animation data (new system)
    json.animationData = this.animationData.toJSON();

    // If randomizing UUIDs, update the curve parameter keys to use new child IDs
    if (randomizeUuid && json.animationData.curves) {
      const newCurves = {};
      for (let paramKey in json.animationData.curves) {
        // paramKey format: "childId.property"
        const parts = paramKey.split('.');
        if (parts.length >= 2) {
          const oldChildId = parts[0];
          const property = parts.slice(1).join('.');
          if (oldChildId in idMap) {
            const newParamKey = `${idMap[oldChildId]}.${property}`;
            newCurves[newParamKey] = json.animationData.curves[paramKey];
            newCurves[newParamKey].parameter = newParamKey;
          } else {
            newCurves[paramKey] = json.animationData.curves[paramKey];
          }
        } else {
          newCurves[paramKey] = json.animationData.curves[paramKey];
        }
      }
      json.animationData.curves = newCurves;
    }

    // Serialize shapes
    json.shapes = this.shapes.map(shape => shape.toJSON(randomizeUuid));

    // Serialize frames (old system - for backwards compatibility)
    if (this.frames) {
      json.frames = [];
      for (let frame of this.frames) {
        if (frame) {
          let frameJson = frame.toJSON(randomizeUuid)
          for (let key in frameJson.keys) {
            if (key in idMap) {
              frameJson.keys[idMap[key]] = frameJson.keys[key]
            }
          }
          json.frames.push(frameJson);
        } else {
          json.frames.push(undefined)
        }
      }
    }

    json.visible = this.visible;
    json.audible = this.audible;
    return json;
  }
  // Get all animated property values for all children at a given time
  getAnimatedState(time) {
    const state = {
      shapes: [...this.shapes],  // Base shapes from layer
      childStates: {}            // Animated states for each child GraphicsObject
    };

    // For each child, get its animated properties at this time
    for (let child of this.children) {
      const childState = {};

      // Animatable properties for GraphicsObjects
      const properties = ['x', 'y', 'rotation', 'scale_x', 'scale_y', 'exists', 'shapeIndex'];

      for (let prop of properties) {
        const paramKey = `${child.idx}.${prop}`;
        const value = this.animationData.interpolate(paramKey, time);

        if (value !== null) {
          childState[prop] = value;
        }
      }

      if (Object.keys(childState).length > 0) {
        state.childStates[child.idx] = childState;
      }
    }

    return state;
  }

  // Helper method to add a keyframe for a child's property
  addKeyframeForChild(childId, property, time, value, interpolation = "linear") {
    const paramKey = `${childId}.${property}`;
    const keyframe = new Keyframe(time, value, interpolation);
    this.animationData.addKeyframe(paramKey, keyframe);
    return keyframe;
  }

  // Helper method to remove a keyframe
  removeKeyframeForChild(childId, property, keyframe) {
    const paramKey = `${childId}.${property}`;
    this.animationData.removeKeyframe(paramKey, keyframe);
  }

  // Helper method to get all keyframes for a child's property
  getKeyframesForChild(childId, property) {
    const paramKey = `${childId}.${property}`;
    const curve = this.animationData.getCurve(paramKey);
    return curve ? curve.keyframes : [];
  }

  /**
   * Add a shape to this layer at the given time
   * Creates AnimationData keyframes for exists, zOrder, and shapeIndex
   */
  addShape(shape, time, sendToBack = false) {
    // Add to shapes array
    this.shapes.push(shape);

    // Determine zOrder
    let zOrder;
    if (sendToBack) {
      zOrder = 0;
      // Increment zOrder for all existing shapes at this time
      for (let existingShape of this.shapes) {
        if (existingShape !== shape) {
          let existingZOrderCurve = this.animationData.curves[`shape.${existingShape.shapeId}.zOrder`];
          if (existingZOrderCurve) {
            for (let kf of existingZOrderCurve.keyframes) {
              if (kf.time === time) {
                kf.value += 1;
              }
            }
          }
        }
      }
    } else {
      zOrder = this.shapes.length - 1;
    }

    // Add AnimationData keyframes
    this.animationData.addKeyframe(`shape.${shape.shapeId}.exists`, new Keyframe(time, 1, "hold"));
    this.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, zOrder, "hold"));
    this.animationData.addKeyframe(`shape.${shape.shapeId}.shapeIndex`, new Keyframe(time, shape.shapeIndex, "linear"));
  }

  /**
   * Remove a specific shape instance from this layer
   * Leaves a "hole" in shapeIndex values so the shape can be restored later
   */
  removeShape(shape) {
    const shapeIndex = this.shapes.indexOf(shape);
    if (shapeIndex < 0) return;

    const shapeId = shape.shapeId;
    const removedShapeIndex = shape.shapeIndex;

    // Remove from array
    this.shapes.splice(shapeIndex, 1);

    // Get shapeIndex curve
    const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`);
    if (shapeIndexCurve) {
      // Remove keyframes that point to this shapeIndex
      const keyframesToRemove = shapeIndexCurve.keyframes.filter(kf => kf.value === removedShapeIndex);
      for (let kf of keyframesToRemove) {
        shapeIndexCurve.removeKeyframe(kf);
      }
      // Note: We intentionally leave a "hole" at this shapeIndex value
      // so the shape can be restored with the same index if undeleted
    }
  }

  getFrame(num) {
    if (this.frames[num]) {
      if (this.frames[num].frameType == "keyframe") {
        return this.frames[num];
      } else if (this.frames[num].frameType == "motion") {
        let frameKeys = {};
        let prevFrame = this.frames[num].prev;
        let nextFrame = this.frames[num].next;
        const t =
          (num - this.frames[num].prevIndex) /
          (this.frames[num].nextIndex - this.frames[num].prevIndex);
        for (let key in prevFrame?.keys) {
          frameKeys[key] = {};
          let prevKeyDict = prevFrame.keys[key];
          let nextKeyDict = nextFrame.keys[key];
          for (let prop in prevKeyDict) {
            frameKeys[key][prop] =
              (1 - t) * prevKeyDict[prop] + t * nextKeyDict[prop];
          }
        }
        let frame = new Frame("motion", "temp");
        frame.keys = frameKeys;
        return frame;
      } else if (this.frames[num].frameType == "shape") {
        let prevFrame = this.frames[num].prev;
        let nextFrame = this.frames[num].next;
        const t =
          (num - this.frames[num].prevIndex) /
          (this.frames[num].nextIndex - this.frames[num].prevIndex);
        let shapes = [];
        for (let shape1 of prevFrame?.shapes) {
          if (shape1.curves.length == 0) continue;
          let shape2 = undefined;
          for (let i of nextFrame.shapes) {
            if (shape1.shapeId == i.shapeId) {
              shape2 = i;
            }
          }
          if (shape2 != undefined) {
            let path1 = [
              {
                type: "M",
                x: shape1.curves[0].points[0].x,
                y: shape1.curves[0].points[0].y,
              },
            ];
            for (let curve of shape1.curves) {
              path1.push({
                type: "C",
                x1: curve.points[1].x,
                y1: curve.points[1].y,
                x2: curve.points[2].x,
                y2: curve.points[2].y,
                x: curve.points[3].x,
                y: curve.points[3].y,
              });
            }
            let path2 = [];
            if (shape2.curves.length > 0) {
              path2.push({
                type: "M",
                x: shape2.curves[0].points[0].x,
                y: shape2.curves[0].points[0].y,
              });
              for (let curve of shape2.curves) {
                path2.push({
                  type: "C",
                  x1: curve.points[1].x,
                  y1: curve.points[1].y,
                  x2: curve.points[2].x,
                  y2: curve.points[2].y,
                  x: curve.points[3].x,
                  y: curve.points[3].y,
                });
              }
            }
            const interpolator = d3.interpolatePathCommands(path1, path2);
            let current = interpolator(t);
            let curves = [];
            let start = current.shift();
            let { x, y } = start;
            for (let curve of current) {
              curves.push(
                new Bezier(
                  x,
                  y,
                  curve.x1,
                  curve.y1,
                  curve.x2,
                  curve.y2,
                  curve.x,
                  curve.y,
                ),
              );
              x = curve.x;
              y = curve.y;
            }
            let lineWidth = lerp(shape1.lineWidth, shape2.lineWidth, t);
            let strokeStyle = lerpColor(
              shape1.strokeStyle,
              shape2.strokeStyle,
              t,
            );
            let fillStyle;
            if (!shape1.fillImage) {
              fillStyle = lerpColor(shape1.fillStyle, shape2.fillStyle, t);
            }
            shapes.push(
              new TempShape(
                start.x,
                start.y,
                curves,
                shape1.lineWidth,
                shape1.stroked,
                shape1.filled,
                strokeStyle,
                fillStyle,
              ),
            );
          }
        }
        let frame = new Frame("shape", "temp");
        frame.shapes = shapes;
        return frame;
      } else {
        for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) {
          if (this.frames[i]?.frameType == "keyframe") {
            let tempFrame = this.frames[i].copy("tempFrame");
            tempFrame.frameType = "normal";
            return tempFrame;
          }
        }
      }
    } else {
      for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) {
        // if (this.frames[i].frameType == "keyframe") {
        //   let tempFrame = this.frames[i].copy("tempFrame")
        //   tempFrame.frameType = "normal"
        return tempFrame;
        // }
      }
    }
  }
  getLatestFrame(num) {
    for (let i = num; i >= 0; i--) {
      if (this.frames[i]?.exists) {
        return this.getFrame(i);
      }
    }
  }
  copy(idx) {
    let newLayer = new VectorLayer(idx.slice(0, 8) + this.idx.slice(8));
    let idxMapping = {};
    for (let child of this.children) {
      let newChild = child.copy(idx);
      idxMapping[child.idx] = newChild.idx;
      newLayer.children.push(newChild);
    }
    newLayer.frames = [];
    for (let frame of this.frames) {
      let newFrame = frame.copy(idx);
      newFrame.keys = {};
      for (let key in frame.keys) {
        newFrame.keys[idxMapping[key]] = structuredClone(frame.keys[key]);
      }
      newLayer.frames.push(newFrame);
    }
    return newLayer;
  }
  addFrame(num, frame, addedFrames) {
    // let updateDest = undefined;
    // if (!this.frames[num]) {
    //   for (const [index, idx] of Object.entries(addedFrames)) {
    //     if (!this.frames[index]) {
    //       this.frames[index] = new Frame("normal", idx);
    //     }
    //   }
    // } else {
    //   if (this.frames[num].frameType == "motion") {
    //     updateDest = "motion";
    //   } else if (this.frames[num].frameType == "shape") {
    //     updateDest = "shape";
    //   }
    // }
    this.frames[num] = frame;
    // if (updateDest) {
    //   this.updateFrameNextAndPrev(num - 1, updateDest);
    //   this.updateFrameNextAndPrev(num + 1, updateDest);
    // }
  }
  addOrChangeFrame(num, frameType, uuid, addedFrames) {
    let latestFrame = this.getLatestFrame(num);
    let newKeyframe = new Frame(frameType, uuid);
    for (let key in latestFrame.keys) {
      newKeyframe.keys[key] = structuredClone(latestFrame.keys[key]);
    }
    for (let shape of latestFrame.shapes) {
      newKeyframe.shapes.push(shape.copy(uuid));
    }
    this.addFrame(num, newKeyframe, addedFrames);
  }
  deleteFrame(uuid, destinationType, replacementUuid) {
    let frame = pointerList[uuid];
    let i = this.frames.indexOf(frame);
    if (i != -1) {
      if (destinationType == undefined) {
        // Determine destination type from surrounding frames
        const prevFrame = this.frames[i - 1];
        const nextFrame = this.frames[i + 1];
        const prevType = prevFrame ? prevFrame.frameType : null;
        const nextType = nextFrame ? nextFrame.frameType : null;
        if (prevType === "motion" || nextType === "motion") {
          destinationType = "motion";
        } else if (prevType === "shape" || nextType === "shape") {
          destinationType = "shape";
        } else if (prevType !== null && nextType !== null) {
          destinationType = "normal";
        } else {
          destinationType = "none";
        }
      }
      if (destinationType == "none") {
        delete this.frames[i];
      } else {
        this.frames[i] = this.frames[i].copy(replacementUuid);
        this.frames[i].frameType = destinationType;
        this.updateFrameNextAndPrev(i, destinationType);
      }
    }
  }
  updateFrameNextAndPrev(num, frameType, lastBefore, firstAfter) {
    if (!this.frames[num] || this.frames[num].frameType == "keyframe") return;
    if (lastBefore == undefined || firstAfter == undefined) {
      let { lastKeyframeBefore, firstKeyframeAfter } = getKeyframesSurrounding(
        this.frames,
        num,
      );
      lastBefore = lastKeyframeBefore;
      firstAfter = firstKeyframeAfter;
    }
    for (let i = lastBefore + 1; i < firstAfter; i++) {
      this.frames[i].frameType = frameType;
      this.frames[i].prev = this.frames[lastBefore];
      this.frames[i].next = this.frames[firstAfter];
      this.frames[i].prevIndex = lastBefore;
      this.frames[i].nextIndex = firstAfter;
    }
  }
  toggleVisibility() {
    this.visible = !this.visible;
    updateUI();
    updateMenu();
    updateLayers();
  }
  getFrameValue(n) {
    const valueAtN = this.frames[n];
    if (valueAtN !== undefined) {
        return { valueAtN, prev: null, next: null, prevIndex: null, nextIndex: null };
    }
    let prev = n - 1;
    let next = n + 1;

    while (prev >= 0 && this.frames[prev] === undefined) {
        prev--;
    }
    while (next < this.frames.length && this.frames[next] === undefined) {
        next++;
    }

    return {
        valueAtN: undefined,
        prev: prev >= 0 ? this.frames[prev] : null,
        next: next < this.frames.length ? this.frames[next] : null,
        prevIndex: prev >= 0 ? prev : null,
        nextIndex: next < this.frames.length ? next : null
    };
  }

  // Get all shapes that exist at the given time
  getVisibleShapes(time) {
    const visibleShapes = [];

    // Calculate tolerance based on framerate (half a frame)
    const halfFrameDuration = 0.5 / config.framerate;

    // Group shapes by shapeId
    const shapesByShapeId = new Map();
    for (let shape of this.shapes) {
      if (shape instanceof TempShape) continue;
      if (!shapesByShapeId.has(shape.shapeId)) {
        shapesByShapeId.set(shape.shapeId, []);
      }
      shapesByShapeId.get(shape.shapeId).push(shape);
    }

    // For each logical shape (shapeId), determine which version to return for EDITING
    for (let [shapeId, shapes] of shapesByShapeId) {
      // Check if this logical shape exists at current time
      let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, time);
      if (existsValue === null || existsValue <= 0) continue;

      // Get shapeIndex curve
      const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`);

      if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) {
        // No shapeIndex curve, return shape with index 0
        const shape = shapes.find(s => s.shapeIndex === 0);
        if (shape) {
          visibleShapes.push(shape);
        }
        continue;
      }

      // Find bracketing keyframes
      const { prev: prevKf, next: nextKf } = shapeIndexCurve.getBracketingKeyframes(time);

      // Get interpolated shapeIndex value
      let shapeIndexValue = shapeIndexCurve.interpolate(time);
      if (shapeIndexValue === null) shapeIndexValue = 0;

      // Check if we're at a keyframe (within half a frame)
      const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < halfFrameDuration;
      const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < halfFrameDuration;

      if (atPrevKeyframe) {
        // At previous keyframe - return that version for editing
        const shape = shapes.find(s => s.shapeIndex === prevKf.value);
        if (shape) visibleShapes.push(shape);
      } else if (atNextKeyframe) {
        // At next keyframe - return that version for editing
        const shape = shapes.find(s => s.shapeIndex === nextKf.value);
        if (shape) visibleShapes.push(shape);
      } else if (prevKf && prevKf.interpolation === 'hold') {
        // Between keyframes but using "hold" interpolation - no morphing
        // Return the previous keyframe's shape since that's what's shown
        const shape = shapes.find(s => s.shapeIndex === prevKf.value);
        if (shape) visibleShapes.push(shape);
      }
      // Otherwise: between keyframes with morphing, return nothing (can't edit a morph)
    }

    return visibleShapes;
  }

  draw(ctx) {
    // super.draw(ctx)
    if (!this.visible) return;

    let cxt = {...context}
    cxt.ctx = ctx

    // Draw shapes using AnimationData curves for exists, zOrder, and shape tweening
    let currentTime = context.activeObject?.currentTime || 0;

    // Group shapes by shapeId for tweening support
    const shapesByShapeId = new Map();
    for (let shape of this.shapes) {
      if (shape instanceof TempShape) continue;
      if (!shapesByShapeId.has(shape.shapeId)) {
        shapesByShapeId.set(shape.shapeId, []);
      }
      shapesByShapeId.get(shape.shapeId).push(shape);
    }

    // Process each logical shape (shapeId)
    let visibleShapes = [];
    for (let [shapeId, shapes] of shapesByShapeId) {
      // Check if this logical shape exists at current time
      let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, currentTime);
      if (existsValue === null || existsValue <= 0) continue;

      // Get z-order
      let zOrder = this.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime);

      // Get shapeIndex curve and surrounding keyframes
      const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`);
      if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) {
        // No shapeIndex curve, just show shape with index 0
        const shape = shapes.find(s => s.shapeIndex === 0);
        if (shape) {
          visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) });
        }
        continue;
      }

      // Find surrounding keyframes
      const { prev: prevKf, next: nextKf } = getKeyframesSurrounding(shapeIndexCurve.keyframes, currentTime);

      // Get interpolated value
      let shapeIndexValue = shapeIndexCurve.interpolate(currentTime);
      if (shapeIndexValue === null) shapeIndexValue = 0;

      // Sort shape versions by shapeIndex
      shapes.sort((a, b) => a.shapeIndex - b.shapeIndex);

      // Determine whether to morph based on whether interpolated value equals a keyframe value
      // Check if we're at either the previous or next keyframe value (no morphing needed)
      const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001;
      const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001;

      if (atPrevKeyframe || atNextKeyframe) {
        // No morphing - display the shape at the keyframe value
        const targetValue = atNextKeyframe ? nextKf.value : prevKf.value;
        const shape = shapes.find(s => s.shapeIndex === targetValue);
        if (shape) {
          visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) });
        }
      } else if (prevKf && nextKf && prevKf.value !== nextKf.value) {
        // Morph between shapes specified by surrounding keyframes
        const shape1 = shapes.find(s => s.shapeIndex === prevKf.value);
        const shape2 = shapes.find(s => s.shapeIndex === nextKf.value);

        if (shape1 && shape2) {
          // Calculate t based on time position between keyframes
          const t = (currentTime - prevKf.time) / (nextKf.time - prevKf.time);
          const morphedShape = shape1.lerpShape(shape2, t);
          visibleShapes.push({ shape: morphedShape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) });
        } else if (shape1) {
          visibleShapes.push({ shape: shape1, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) });
        } else if (shape2) {
          visibleShapes.push({ shape: shape2, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape2) });
        }
      } else if (nextKf) {
        // Only next keyframe exists, show that shape
        const shape = shapes.find(s => s.shapeIndex === nextKf.value);
        if (shape) {
          visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) });
        }
      }
    }

    // Sort by zOrder (lowest first = back, highest last = front)
    visibleShapes.sort((a, b) => a.zOrder - b.zOrder);

    // Draw sorted shapes
    for (let { shape, selected } of visibleShapes) {
      cxt.selected = selected;
      shape.draw(cxt);
    }

    // Draw children (GraphicsObjects) using AnimationData curves
    for (let child of this.children) {
      // Check if child exists at current time using AnimationData
      // null means no exists curve (defaults to visible)
      const existsValue = this.animationData.interpolate(`object.${child.idx}.exists`, currentTime);
      if (existsValue !== null && existsValue <= 0) continue;

      // Get child properties from AnimationData curves
      const childX = this.animationData.interpolate(`object.${child.idx}.x`, currentTime);
      const childY = this.animationData.interpolate(`object.${child.idx}.y`, currentTime);
      const childRotation = this.animationData.interpolate(`object.${child.idx}.rotation`, currentTime);
      const childScaleX = this.animationData.interpolate(`object.${child.idx}.scale_x`, currentTime);
      const childScaleY = this.animationData.interpolate(`object.${child.idx}.scale_y`, currentTime);

      // Apply properties if they exist in AnimationData
      if (childX !== null) child.x = childX;
      if (childY !== null) child.y = childY;
      if (childRotation !== null) child.rotation = childRotation;
      if (childScaleX !== null) child.scale_x = childScaleX;
      if (childScaleY !== null) child.scale_y = childScaleY;

      // Draw the child if not in objectStack
      if (!context.objectStack.includes(child)) {
        const transform = ctx.getTransform();
        ctx.translate(child.x, child.y);
        ctx.scale(child.scale_x, child.scale_y);
        ctx.rotate(child.rotation);
        child.draw(ctx);

        // Draw selection outline if selected
        if (context.selection.includes(child)) {
          ctx.lineWidth = 1;
          ctx.strokeStyle = "#00ffff";
          ctx.beginPath();
          let bbox = child.bbox();
          ctx.rect(bbox.x.min - child.x, bbox.y.min - child.y, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min);
          ctx.stroke();
        }
        ctx.setTransform(transform);
      }
    }
    // Draw activeShape regardless of whether frame exists
    if (this.activeShape) {
      console.log("Layer.draw: Drawing activeShape", this.activeShape);
      this.activeShape.draw(cxt)
      console.log("Layer.draw: Drew activeShape");
    }
  }
  bbox() {
    let bbox = super.bbox();
    let currentTime = context.activeObject?.currentTime || 0;

    // Get visible shapes at current time using AnimationData
    const visibleShapes = this.getVisibleShapes(currentTime);

    if (visibleShapes.length > 0 && bbox === undefined) {
      bbox = structuredClone(visibleShapes[0].boundingBox);
    }
    for (let shape of visibleShapes) {
      growBoundingBox(bbox, shape.boundingBox);
    }
    return bbox;
  }
  mousedown(x, y) {
    console.log("Layer.mousedown called - this:", this.name, "activeLayer:", context.activeLayer?.name, "context.mode:", context.mode);
    const mouse = {x: x, y: y}
    if (this==context.activeLayer) {
      console.log("This IS the active layer");
      switch(context.mode) {
        case "rectangle":
        case "ellipse":
        case "draw":
          console.log("Creating shape for context.mode:", context.mode);
          this.clicked = true
          this.activeShape = new Shape(x, y, context, this, uuidv4())
          this.lastMouse = mouse;
          console.log("Shape created:", this.activeShape);
          break;
        case "select":
        case "transform":
          break;
        case "paint_bucket":
          debugCurves = [];
          debugPoints = [];
          let epsilon = context.fillGaps;
          let regionPoints;

          // First, see if there's an existing shape to change the color of
          let currentTime = context.activeObject?.currentTime || 0;
          let visibleShapes = this.getVisibleShapes(currentTime);
          let pointShape = getShapeAtPoint(mouse, visibleShapes);

          if (pointShape) {
            actions.colorShape.create(pointShape, context.fillStyle);
            break;
          }

          // We didn't find an existing region to paintbucket, see if we can make one
          try {
            regionPoints = floodFillRegion(
              mouse,
              epsilon,
              config.fileWidth,
              config.fileHeight,
              context,
              debugPoints,
              debugPaintbucket,
              visibleShapes,
            );
          } catch (e) {
            updateUI();
            throw e;
          }
          if (regionPoints.length > 0 && regionPoints.length < 10) {
            // probably a very small area, rerun with minimum epsilon
            regionPoints = floodFillRegion(
              mouse,
              1,
              config.fileWidth,
              config.fileHeight,
              context,
              debugPoints,
              false,
              visibleShapes,
            );
          }
          let points = [];
          for (let point of regionPoints) {
            points.push([point.x, point.y]);
          }
          let cxt = {
            ...context,
            fillShape: true,
            strokeShape: false,
            sendToBack: true,
          };
          let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this);
          shape.fromPoints(points, 1);
          actions.addShape.create(context.activeObject, shape, cxt);
          break;
      }
    }
  }
  mousemove(x, y) {
    const mouse = {x: x, y: y}
    if (this==context.activeLayer) {
      switch (context.mode) {
        case "draw":
          if (this.activeShape) {
            if (vectorDist(mouse, context.lastMouse) > minSegmentSize) {
              this.activeShape.addLine(x, y);
              this.lastMouse = mouse;
            }
          }
          break;
        case "rectangle":
          if (this.activeShape) {
            this.activeShape.clear();
            this.activeShape.addLine(x, this.activeShape.starty);
            this.activeShape.addLine(x, y);
            this.activeShape.addLine(this.activeShape.startx, y);
            this.activeShape.addLine(
              this.activeShape.startx,
              this.activeShape.starty,
            );
            this.activeShape.update();
          }
          break;
        case "ellipse":
          if (this.activeShape) {
            let midX = (mouse.x + this.activeShape.startx) / 2;
            let midY = (mouse.y + this.activeShape.starty) / 2;
            let xDiff = (mouse.x - this.activeShape.startx) / 2;
            let yDiff = (mouse.y - this.activeShape.starty) / 2;
            let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4
            this.activeShape.clear();
            this.activeShape.addCurve(
              new Bezier(
                midX,
                this.activeShape.starty,
                midX + ellipseConst * xDiff,
                this.activeShape.starty,
                mouse.x,
                midY - ellipseConst * yDiff,
                mouse.x,
                midY,
              ),
            );
            this.activeShape.addCurve(
              new Bezier(
                mouse.x,
                midY,
                mouse.x,
                midY + ellipseConst * yDiff,
                midX + ellipseConst * xDiff,
                mouse.y,
                midX,
                mouse.y,
              ),
            );
            this.activeShape.addCurve(
              new Bezier(
                midX,
                mouse.y,
                midX - ellipseConst * xDiff,
                mouse.y,
                this.activeShape.startx,
                midY + ellipseConst * yDiff,
                this.activeShape.startx,
                midY,
              ),
            );
            this.activeShape.addCurve(
              new Bezier(
                this.activeShape.startx,
                midY,
                this.activeShape.startx,
                midY - ellipseConst * yDiff,
                midX - ellipseConst * xDiff,
                this.activeShape.starty,
                midX,
                this.activeShape.starty,
              ),
            );
          }
          break;
      }
    }
  }
  mouseup(x, y) {
    console.log("Layer.mouseup called - context.mode:", context.mode, "activeShape:", this.activeShape);
    this.clicked = false
    if (this==context.activeLayer) {
      switch (context.mode) {
        case "draw":
          if (this.activeShape) {
            this.activeShape.addLine(x, y);
            this.activeShape.simplify(context.simplifyMode);
          }
        case "rectangle":
        case "ellipse":
          if (this.activeShape) {
            console.log("Adding shape via actions.addShape.create");
            actions.addShape.create(context.activeObject, this.activeShape);
            console.log("Shape added, clearing activeShape");
            this.activeShape = undefined;
          }
          break;
      }
    }
  }
}

class AudioTrack {
  constructor(uuid, name, type = 'audio') {
    // ID and name
    if (!uuid) {
      this.idx = uuidv4();
    } else {
      this.idx = uuid;
    }
    this.name = name || (type === 'midi' ? "MIDI" : "Audio");
    this.type = type; // 'audio' or 'midi'
    this.audible = true;
    this.visible = true;  // For consistency with Layer (audio tracks are always "visible" in timeline)

    // AnimationData for automation curves (like Layer)
    this.animationData = new AnimationData(this);

    // Read-only empty arrays for layer compatibility (audio tracks don't have shapes/children)
    Object.defineProperty(this, 'shapes', {
      value: Object.freeze([]),
      writable: false,
      enumerable: true,
      configurable: false
    });
    Object.defineProperty(this, 'children', {
      value: Object.freeze([]),
      writable: false,
      enumerable: true,
      configurable: false
    });

    // Reference to DAW backend track
    this.audioTrackId = null;

    // Audio clips (for audio tracks) or MIDI clips (for MIDI tracks)
    this.clips = []; // { clipId, poolIndex, name, startTime, duration, offset } or MIDI clip data

    // Timeline display settings (for track hierarchy)
    this.collapsed = false
    this.curvesMode = 'segment'  // 'segment' | 'keyframe' | 'curve'
    this.curvesHeight = 150  // Height in pixels when curves are in curve view

    pointerList[this.idx] = this;
  }

  // Sync automation to backend using generic parameter setter
  async syncAutomation(time) {
    if (this.audioTrackId === null) return;

    // Get all automation parameters and sync them
    const params = ['volume', 'mute', 'solo', 'pan'];
    for (const param of params) {
      const value = this.animationData.interpolate(`track.${param}`, time);
      if (value !== null) {
        await invoke('audio_set_track_parameter', {
          trackId: this.audioTrackId,
          parameter: param,
          value
        });
      }
    }
  }

  // Get all automation parameter names
  getAutomationParameters() {
    return [
      'track.volume',
      'track.pan',
      'track.mute',
      'track.solo',
      ...this.clips.flatMap(clip => [
        `clip.${clip.clipId}.gain`,
        `clip.${clip.clipId}.pan`
      ])
    ];
  }

  // Initialize the audio track in the DAW backend
  async initializeTrack() {
    if (this.audioTrackId !== null) {
      console.warn('Track already initialized');
      return;
    }

    try {
      const params = {
        name: this.name,
        trackType: this.type
      };

      // Add instrument parameter for MIDI tracks
      if (this.type === 'midi' && this.instrument) {
        params.instrument = this.instrument;
      }

      const trackId = await invoke('audio_create_track', params);
      this.audioTrackId = trackId;
      console.log(`${this.type === 'midi' ? 'MIDI' : 'Audio'} track created:`, this.name, 'with ID:', trackId);
    } catch (error) {
      console.error(`Failed to create ${this.type} track:`, error);
      throw error;
    }
  }

  // Load an audio file and add it to the pool
  // Returns metadata including: pool_index, duration, sample_rate, channels, waveform
  async loadAudioFile(path) {
    try {
      const metadata = await invoke('audio_load_file', {
        path: path
      });
      console.log('Audio file loaded:', path, 'metadata:', metadata);
      return metadata;
    } catch (error) {
      console.error('Failed to load audio file:', error);
      throw error;
    }
  }

  // Add a clip to this track
  async addClip(poolIndex, startTime, duration, offset = 0.0, name = '', waveform = null) {
    if (this.audioTrackId === null) {
      throw new Error('Track not initialized. Call initializeTrack() first.');
    }

    try {
      await invoke('audio_add_clip', {
        trackId: this.audioTrackId,
        poolIndex,
        startTime,
        duration,
        offset
      });

      // Store clip metadata locally
      // Note: clipId will be assigned by backend, we'll get it via ClipAdded event
      this.clips.push({
        clipId: this.clips.length, // Temporary ID
        poolIndex,
        name: name || `Clip ${this.clips.length + 1}`,
        startTime,
        duration,
        offset,
        waveform  // Store waveform data for rendering
      });

      console.log('Clip added to track', this.audioTrackId);
    } catch (error) {
      console.error('Failed to add clip:', error);
      throw error;
    }
  }

  static fromJSON(json) {
    const audioTrack = new AudioTrack(json.idx, json.name, json.trackType || 'audio');

    // Load AnimationData if present
    if (json.animationData) {
      audioTrack.animationData = AnimationData.fromJSON(json.animationData, audioTrack);
    }

    // Load clips if present
    if (json.clips) {
      audioTrack.clips = json.clips.map(clip => {
        const clipData = {
          clipId: clip.clipId,
          name: clip.name,
          startTime: clip.startTime,
          duration: clip.duration,
          offset: clip.offset || 0,  // Default to 0 if not present
        };

        // Restore audio-specific fields
        if (clip.poolIndex !== undefined) {
          clipData.poolIndex = clip.poolIndex;
        }

        // Restore MIDI-specific fields
        if (clip.notes) {
          clipData.notes = clip.notes;
        }

        return clipData;
      });
    }

    audioTrack.audible = json.audible;
    return audioTrack;
  }

  toJSON(randomizeUuid = false) {
    const json = {
      type: "AudioTrack",
      idx: randomizeUuid ? uuidv4() : this.idx,
      name: randomizeUuid ? this.name + " copy" : this.name,
      trackType: this.type, // 'audio' or 'midi'
      audible: this.audible,

      // AnimationData (includes automation curves)
      animationData: this.animationData.toJSON(),

      // Clips
      clips: this.clips.map(clip => {
        const clipData = {
          clipId: clip.clipId,
          name: clip.name,
          startTime: clip.startTime,
          duration: clip.duration,
        };

        // Add audio-specific fields
        if (clip.poolIndex !== undefined) {
          clipData.poolIndex = clip.poolIndex;
          clipData.offset = clip.offset;
        }

        // Add MIDI-specific fields
        if (clip.notes) {
          clipData.notes = clip.notes;
        }

        return clipData;
      })
    };

    return json;
  }

  copy(idx) {
    // Serialize and deserialize with randomized UUID
    const json = this.toJSON(true);
    json.idx = idx.slice(0, 8) + this.idx.slice(8);
    return AudioTrack.fromJSON(json);
  }
}

class VideoLayer extends Widget {
  constructor(uuid, name) {
    super(0, 0);
    if (!uuid) {
      this.idx = uuidv4();
    } else {
      this.idx = uuid;
    }
    this.name = name || "Video";
    this.type = 'video';
    this.visible = true;
    this.audible = true;
    this.animationData = new AnimationData(this);

    // Empty arrays for layer compatibility
    Object.defineProperty(this, 'shapes', {
      value: Object.freeze([]),
      writable: false,
      enumerable: true,
      configurable: false
    });
    Object.defineProperty(this, 'children', {
      value: Object.freeze([]),
      writable: false,
      enumerable: true,
      configurable: false
    });

    // Video clips on this layer
    // { clipId, poolIndex, name, startTime, duration, offset, width, height }
    this.clips = [];

    // Associated audio track (if video has audio)
    this.linkedAudioTrack = null;  // Reference to AudioTrack

    // Performance settings
    this.useJpegCompression = false;  // JPEG compression adds more overhead than it saves (default: false)
    this.prefetchCount = 3;  // Number of frames to prefetch ahead of playhead

    // WebSocket streaming (experimental - zero-copy RGBA frames from Rust)
    this.useWebSocketStreaming = true;  // Use WebSocket streaming (enabled for testing)
    this.wsConnected = false;  // Track WebSocket connection status

    // Timeline display
    this.collapsed = false;
    this.curvesMode = 'segment';
    this.curvesHeight = 150;

    pointerList[this.idx] = this;
  }

  async addClip(poolIndex, startTime, duration, offset = 0.0, name = '', sourceDuration = null, metadata = null) {
    const poolInfo = await invoke('video_get_pool_info', { poolIndex });
    // poolInfo is [width, height, fps] tuple from Rust
    const [width, height, fps] = poolInfo;

    const clip = {
      clipId: this.clips.length,
      poolIndex,
      name: name || `Video ${this.clips.length + 1}`,
      startTime,
      duration,
      offset,
      width,
      height,
      sourceDuration: sourceDuration || duration,  // Store original file duration
      httpUrl: metadata?.http_url || null,
      isBrowserCompatible: metadata?.is_browser_compatible || false,
      transcoding: metadata?.transcoding || false,
      videoElement: null,  // Will hold HTML5 video element if using browser playback
      useBrowserVideo: false,  // Switch to true when video element is ready
      isPlaying: false,  // Track if video element is actively playing
    };

    this.clips.push(clip);

    console.log(`Video clip added: ${name}, ${width}x${height}, duration: ${duration}s, browser-compatible: ${clip.isBrowserCompatible}, http_url: ${clip.httpUrl}`);

    // If using WebSocket streaming, connect and subscribe
    if (this.useWebSocketStreaming) {
      // Connect to WebSocket if not already connected
      if (!this.wsConnected) {
        try {
          await frameReceiver.connect();
          this.wsConnected = true;
          console.log(`[Video] WebSocket connected for streaming`);
        } catch (error) {
          console.error('[Video] Failed to connect WebSocket, falling back to browser video:', error);
          this.useWebSocketStreaming = false;
        }
      }

      // Subscribe to frames for this pool
      if (this.wsConnected) {
        frameReceiver.subscribe(poolIndex, (imageData, timestamp) => {
          // Store received frame
          clip.wsCurrentFrame = imageData;
          clip.wsLastTimestamp = timestamp;
          // console.log(`[Video WS] Received frame ${width}x${height} @ ${timestamp.toFixed(3)}s`);

          // Trigger UI redraw
          if (updateUI) {
            updateUI();
          }
        });
        console.log(`[Video] Subscribed to WebSocket frames for pool ${poolIndex}`);
      }
    }
    // Otherwise use browser video if available
    else if (clip.httpUrl) {
      await this._createVideoElement(clip);
      clip.useBrowserVideo = true;
    }
    // If transcoding is in progress, start polling
    else if (clip.transcoding) {
      console.log(`[Video] Starting transcode polling for ${clip.name}`);
      this._pollTranscodeStatus(clip);
    }
  }

  async _createVideoElement(clip) {
    // Create hidden video element for hardware-accelerated decoding
    const video = document.createElement('video');
    // Hide video element using opacity (browsers may skip decoding if off-screen)
    video.style.position = 'fixed';
    video.style.bottom = '0';
    video.style.right = '0';
    video.style.width = '1px';
    video.style.height = '1px';
    video.style.opacity = '0.01';  // Nearly invisible but not 0 (some browsers optimize opacity:0)
    video.style.pointerEvents = 'none';
    video.style.zIndex = '-1';
    video.preload = 'auto';
    video.muted = true;  // Mute video element (audio plays separately)
    video.playsInline = true;
    video.autoplay = false;
    video.crossOrigin = 'anonymous';  // Required for canvas drawing - prevent CORS taint

    // Add event listeners for debugging
    video.addEventListener('loadedmetadata', () => {
      console.log(`[Video] Loaded metadata for ${clip.name}: ${video.videoWidth}x${video.videoHeight}, duration: ${video.duration}s`);
    });

    video.addEventListener('loadeddata', () => {
      console.log(`[Video] Loaded data for ${clip.name}, readyState: ${video.readyState}`);
    });

    video.addEventListener('canplay', () => {
      console.log(`[Video] Can play ${clip.name}, duration: ${video.duration}s`);
      // Mark video as ready for seeking once we can play AND have valid duration
      if (video.duration > 0 && !isNaN(video.duration) && video.duration !== Infinity) {
        clip.videoReady = true;
        console.log(`[Video] Video is ready for seeking`);
      }
    });

    // When seek completes, trigger UI redraw to show the new frame
    video.addEventListener('seeked', () => {
      if (updateUI) {
        updateUI();
      }
    });

    video.addEventListener('error', (e) => {
      const error = video.error;
      const errorMessages = {
        1: 'MEDIA_ERR_ABORTED - Fetching aborted',
        2: 'MEDIA_ERR_NETWORK - Network error',
        3: 'MEDIA_ERR_DECODE - Decoding error',
        4: 'MEDIA_ERR_SRC_NOT_SUPPORTED - Format not supported or file not accessible'
      };
      const errorMsg = errorMessages[error?.code] || 'Unknown error';
      console.error(`[Video] Error loading ${clip.name}: ${errorMsg}`, error?.message);
    });

    // Use HTTP URL from local server (supports range requests for seeking)
    video.src = clip.httpUrl;

    // Try to load the video
    video.load();

    document.body.appendChild(video);
    clip.videoElement = video;

    console.log(`[Video] Created video element for clip ${clip.name}: ${clip.httpUrl}`);
  }

  async _pollTranscodeStatus(clip) {
    // Poll transcode status every 2 seconds
    const pollInterval = setInterval(async () => {
      try {
        const status = await invoke('video_get_transcode_status', { poolIndex: clip.poolIndex });

        if (status && status[2]) {  // [path, progress, completed, httpUrl]
          // Transcode complete!
          clearInterval(pollInterval);
          const [outputPath, progress, completed, httpUrl] = status;

          clip.transcodedPath = outputPath;
          clip.httpUrl = httpUrl;
          clip.transcoding = false;
          clip.useBrowserVideo = true;

          console.log(`[Video] Transcode complete for ${clip.name}, switching to browser playback: ${httpUrl}`);

          // Create video element for browser playback
          await this._createVideoElement(clip);
        }
      } catch (error) {
        console.error('Failed to poll transcode status:', error);
        clearInterval(pollInterval);
      }
    }, 2000);
  }

  // Pre-fetch frames for current time (call before draw)
  async updateFrame(currentTime) {
    // Prevent concurrent calls - if already updating, skip
    if (this.updateInProgress) {
      return;
    }
    this.updateInProgress = true;

    try {
      for (let clip of this.clips) {
        // Check if clip is active at current time
        if (currentTime < clip.startTime ||
            currentTime >= clip.startTime + clip.duration) {
          clip.currentFrame = null;
          clip.wsCurrentFrame = null;

          // Pause video element if we left its time range
          if (clip.videoElement && clip.isPlaying) {
            clip.videoElement.pause();
            clip.isPlaying = false;
          }

          continue;
        }

        // If using WebSocket streaming
        if (this.useWebSocketStreaming && this.wsConnected) {
          const videoTime = clip.offset + (currentTime - clip.startTime);

          // Request frame via WebSocket streaming (non-blocking)
          // The frame will arrive via the subscription callback and trigger a redraw
          try {
            await invoke('video_stream_frame', {
              poolIndex: clip.poolIndex,
              timestamp: videoTime
            });
          } catch (error) {
            console.error('[Video WS] Failed to stream frame:', error);
          }

          continue;  // Skip other frame fetching methods
        }

        // If using browser video element
        if (clip.useBrowserVideo && clip.videoElement) {
          const videoTime = clip.offset + (currentTime - clip.startTime);

          // Don't do anything until video is fully ready
          if (!clip.videoReady) {
            if (!clip._notReadyWarned) {
              console.warn(`[Video updateFrame] Video not ready yet (duration=${clip.videoElement.duration})`);
              clip._notReadyWarned = true;
            }
            continue;
          }

          // During playback: let video play naturally
          if (context.playing) {
            // Check if we just entered this clip (need to start playing)
            if (!clip.isPlaying) {
              // Start playing one frame ahead to compensate for canvas drawing lag
              const frameDuration = 1 / (clip.fps || 30); // Use clip's actual framerate
              const maxVideoTime = clip.sourceDuration - frameDuration; // Don't seek past end
              const startTime = Math.min(videoTime + frameDuration, maxVideoTime);
              console.log(`[Video updateFrame] Starting playback at ${startTime.toFixed(3)}s (compensated by ${frameDuration.toFixed(3)}s for ${clip.fps}fps)`);
              clip.videoElement.currentTime = startTime;
              clip.videoElement.play().catch(e => console.error('Failed to play video:', e));
              clip.isPlaying = true;
            }
            // Otherwise, let it play naturally - don't seek!
          }
          // When scrubbing (not playing): seek to exact position and pause
          else {
            if (clip.isPlaying) {
              clip.videoElement.pause();
              clip.isPlaying = false;
            }

            // Only seek if the time is actually different
            if (!clip.videoElement.seeking) {
              const timeDiff = Math.abs(clip.videoElement.currentTime - videoTime);
              if (timeDiff > 0.016) { // ~1 frame tolerance at 60fps
                clip.videoElement.currentTime = videoTime;
              }
            }
          }

          continue;  // Skip frame fetching
        }

        // Use frame batching for frame-based playback

        // Initialize frame cache if needed
        if (!clip.frameCache) {
          clip.frameCache = new Map();
        }

        // Check if current frame is already cached
        if (clip.frameCache.has(currentVideoTimestamp)) {
          clip.currentFrame = clip.frameCache.get(currentVideoTimestamp);
          clip.lastFetchedTimestamp = currentVideoTimestamp;
          continue;
        }

        // Skip if already fetching
        if (clip.fetchInProgress) {
          continue;
        }

        clip.fetchInProgress = true;

        try {
          // Calculate timestamps to prefetch (current + next N frames)
          const frameDuration = 1 / 30; // Assume 30fps for now, could get from clip metadata
          const timestamps = [];
          for (let i = 0; i < this.prefetchCount; i++) {
            const ts = currentVideoTimestamp + (i * frameDuration);
            // Don't exceed clip duration
            if (ts <= clip.offset + clip.sourceDuration) {
              timestamps.push(ts);
            }
          }

          if (timestamps.length === 0) {
            continue;
          }

          const t_start = performance.now();

          // Request batch of frames using IPC Channel
          const batchDataPromise = new Promise((resolve, reject) => {
            const channel = new Channel();

            channel.onmessage = (data) => {
              resolve(data);
            };

            invoke('video_get_frames_batch', {
              poolIndex: clip.poolIndex,
              timestamps: timestamps,
              useJpeg: this.useJpegCompression,
              channel: channel
            }).catch(reject);
          });

          let batchData = await batchDataPromise;
          const t_after_ipc = performance.now();

          // Ensure data is Uint8Array
          if (!(batchData instanceof Uint8Array)) {
            batchData = new Uint8Array(batchData);
          }

          // Unpack the batch format: [frame_count: u32][frame1_size: u32][frame1_data...][frame2_size: u32][frame2_data...]...
          const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength);
          let offset = 0;

          // Read frame count
          const frameCount = view.getUint32(offset, true); // little-endian
          offset += 4;

          if (frameCount !== timestamps.length) {
            console.warn(`Expected ${timestamps.length} frames, got ${frameCount}`);
          }

          const t_before_conversion = performance.now();

          // Process each frame
          for (let i = 0; i < frameCount; i++) {
            // Read frame size
            const frameSize = view.getUint32(offset, true);
            offset += 4;

            // Extract frame data
            const frameData = new Uint8Array(batchData.buffer, batchData.byteOffset + offset, frameSize);
            offset += frameSize;

            let imageData;

            if (this.useJpegCompression) {
              // Decode JPEG using createImageBitmap
              const blob = new Blob([frameData], { type: 'image/jpeg' });
              const imageBitmap = await createImageBitmap(blob);

              // Create temporary canvas to extract ImageData
              const tempCanvas = document.createElement('canvas');
              tempCanvas.width = clip.width;
              tempCanvas.height = clip.height;
              const tempCtx = tempCanvas.getContext('2d');
              tempCtx.drawImage(imageBitmap, 0, 0);
              imageData = tempCtx.getImageData(0, 0, clip.width, clip.height);

              imageBitmap.close();
            } else {
              // Raw RGBA data
              const expectedSize = clip.width * clip.height * 4;

              if (frameData.length !== expectedSize) {
                console.error(`Invalid frame ${i} data size: got ${frameData.length}, expected ${expectedSize}`);
                continue;
              }

              imageData = new ImageData(
                new Uint8ClampedArray(frameData),
                clip.width,
                clip.height
              );
            }

            // Create canvas for this frame
            const frameCanvas = document.createElement('canvas');
            frameCanvas.width = clip.width;
            frameCanvas.height = clip.height;
            const frameCtx = frameCanvas.getContext('2d');
            frameCtx.putImageData(imageData, 0, 0);

            // Cache the frame
            clip.frameCache.set(timestamps[i], frameCanvas);

            // Set as current frame if it's the first one
            if (i === 0) {
              clip.currentFrame = frameCanvas;
              clip.lastFetchedTimestamp = timestamps[i];
            }
          }

          const t_after_conversion = performance.now();

          // Limit cache size to avoid memory issues
          const maxCacheSize = this.prefetchCount * 2;
          if (clip.frameCache.size > maxCacheSize) {
            // Remove oldest entries (simple LRU by keeping only recent timestamps)
            const sortedKeys = Array.from(clip.frameCache.keys()).sort((a, b) => a - b);
            const toRemove = sortedKeys.slice(0, sortedKeys.length - maxCacheSize);
            for (let key of toRemove) {
              clip.frameCache.delete(key);
            }
          }

          // Log timing breakdown
          const total_time = t_after_conversion - t_start;
          const ipc_time = t_after_ipc - t_start;
          const conversion_time = t_after_conversion - t_before_conversion;
          const compression_mode = this.useJpegCompression ? 'JPEG' : 'RAW';
          const avg_per_frame = total_time / frameCount;

          console.log(`[JS Video Batch ${compression_mode}] Fetched ${frameCount} frames | Total: ${total_time.toFixed(1)}ms | IPC: ${ipc_time.toFixed(1)}ms (${(ipc_time/total_time*100).toFixed(0)}%) | Convert: ${conversion_time.toFixed(1)}ms | Avg/frame: ${avg_per_frame.toFixed(1)}ms | Size: ${(batchData.length/1024/1024).toFixed(2)}MB`);
        } catch (error) {
          console.error('Failed to get video frames batch:', error);
          clip.currentFrame = null;
        } finally {
          clip.fetchInProgress = false;
        }
      }
    } finally {
      this.updateInProgress = false;
    }
  }

  // Draw cached frames (synchronous)
  draw(cxt, currentTime) {
    if (!this.visible) {
      return;
    }

    const ctx = cxt.ctx || cxt;

    // Use currentTime from context if not provided
    if (currentTime === undefined) {
      currentTime = cxt.activeObject?.currentTime || 0;
    }

    for (let clip of this.clips) {
      // Check if clip is active at current time
      if (currentTime < clip.startTime ||
          currentTime >= clip.startTime + clip.duration) {
        continue;
      }

      // Debug: log what path we're taking
      // if (!clip._drawPathLogged) {
      //   console.log(`[Video Draw] useWebSocketStreaming=${this.useWebSocketStreaming}, wsCurrentFrame=${!!clip.wsCurrentFrame}, useBrowserVideo=${clip.useBrowserVideo}, videoElement=${!!clip.videoElement}, currentFrame=${!!clip.currentFrame}`);
      //   clip._drawPathLogged = true;
      // }

      // Prefer WebSocket streaming if available
      if (this.useWebSocketStreaming && clip.wsCurrentFrame) {
        try {
          // Create a temporary canvas to hold the ImageData
          if (!clip._wsCanvas) {
            clip._wsCanvas = document.createElement('canvas');
          }
          const tempCanvas = clip._wsCanvas;

          // Set temp canvas size to match ImageData dimensions
          if (tempCanvas.width !== clip.wsCurrentFrame.width || tempCanvas.height !== clip.wsCurrentFrame.height) {
            tempCanvas.width = clip.wsCurrentFrame.width;
            tempCanvas.height = clip.wsCurrentFrame.height;
          }

          // Put ImageData on temp canvas (zero-copy)
          const tempCtx = tempCanvas.getContext('2d');
          tempCtx.putImageData(clip.wsCurrentFrame, 0, 0);

          // Scale to fit canvas while maintaining aspect ratio
          const canvasWidth = config.fileWidth;
          const canvasHeight = config.fileHeight;
          const scale = Math.min(
            canvasWidth / clip.width,
            canvasHeight / clip.height
          );
          const scaledWidth = clip.width * scale;
          const scaledHeight = clip.height * scale;
          const x = (canvasWidth - scaledWidth) / 2;
          const y = (canvasHeight - scaledHeight) / 2;

          // Draw scaled to main canvas (GPU-accelerated)
          ctx.drawImage(tempCanvas, x, y, scaledWidth, scaledHeight);
        } catch (error) {
          console.error('[Video WS Draw] Failed to draw WebSocket frame:', error);
        }
      }
      // Prefer browser video element if available
      else if (clip.useBrowserVideo && clip.videoElement) {
        // Debug: log readyState issues
        if (clip.videoElement.readyState < 2) {
          if (!clip._readyStateWarned) {
            console.warn(`[Video] Video not ready: readyState=${clip.videoElement.readyState}, src=${clip.videoElement.src}`);
            clip._readyStateWarned = true;
          }
        }

        // Draw if video is ready (shows last frame while seeking, updates when seek completes)
        if (clip.videoElement.readyState >= 2) {
          try {
            // Calculate expected video time
            const expectedVideoTime = clip.offset + (currentTime - clip.startTime);
            const actualVideoTime = clip.videoElement.currentTime;
            const timeDiff = Math.abs(expectedVideoTime - actualVideoTime);

            // Debug: log if time is significantly different
            if (timeDiff > 0.1 && (!clip._lastTimeDiffWarning || Date.now() - clip._lastTimeDiffWarning > 1000)) {
              console.warn(`[Video Draw] Time mismatch: expected ${expectedVideoTime.toFixed(2)}s, actual ${actualVideoTime.toFixed(2)}s, diff=${timeDiff.toFixed(2)}s`);
              clip._lastTimeDiffWarning = Date.now();
            }

            // Debug: log successful draw periodically
            if (!clip._lastDrawLog || Date.now() - clip._lastDrawLog > 1000) {
              console.log(`[Video Draw] Drawing at currentTime=${actualVideoTime.toFixed(2)}s (expected ${expectedVideoTime.toFixed(2)}s)`);
              clip._lastDrawLog = Date.now();
            }

            // Scale to fit canvas while maintaining aspect ratio
            const canvasWidth = config.fileWidth;
            const canvasHeight = config.fileHeight;
            const scale = Math.min(
              canvasWidth / clip.videoElement.videoWidth,
              canvasHeight / clip.videoElement.videoHeight
            );
            const scaledWidth = clip.videoElement.videoWidth * scale;
            const scaledHeight = clip.videoElement.videoHeight * scale;
            const x = (canvasWidth - scaledWidth) / 2;
            const y = (canvasHeight - scaledHeight) / 2;

            // Debug: draw a test rectangle to verify canvas is working
            if (!clip._canvasTestDone) {
              ctx.save();
              ctx.fillStyle = 'red';
              ctx.fillRect(10, 10, 100, 100);
              ctx.restore();
              console.log(`[Video Draw] Drew test rectangle at (10, 10, 100, 100)`);
              console.log(`[Video Draw] Canvas dimensions: ${canvasWidth}x${canvasHeight}`);
              console.log(`[Video Draw] Scaled video dimensions: ${scaledWidth}x${scaledHeight} at (${x}, ${y})`);
              clip._canvasTestDone = true;
            }

            // Debug: Check if video element has dimensions
            if (!clip._videoDimensionsLogged) {
              console.log(`[Video Draw] Video element dimensions: videoWidth=${clip.videoElement.videoWidth}, videoHeight=${clip.videoElement.videoHeight}, naturalWidth=${clip.videoElement.videoWidth}, naturalHeight=${clip.videoElement.videoHeight}`);
              console.log(`[Video Draw] Video element state: paused=${clip.videoElement.paused}, ended=${clip.videoElement.ended}, seeking=${clip.videoElement.seeking}, readyState=${clip.videoElement.readyState}`);
              clip._videoDimensionsLogged = true;
            }

            ctx.drawImage(clip.videoElement, x, y, scaledWidth, scaledHeight);

            // Debug: Sample a pixel to see if video is actually drawing
            if (!clip._pixelTestDone) {
              const imageData = ctx.getImageData(canvasWidth / 2, canvasHeight / 2, 1, 1);
              const pixel = imageData.data;
              console.log(`[Video Draw] Center pixel after drawImage: R=${pixel[0]}, G=${pixel[1]}, B=${pixel[2]}, A=${pixel[3]}`);
              clip._pixelTestDone = true;
            }
          } catch (error) {
            console.error('Failed to draw video element:', error);
          }
        }
      }
      // Fall back to cached frame if available
      else if (clip.currentFrame) {
        try {
          // Scale to fit canvas while maintaining aspect ratio
          const canvasWidth = config.fileWidth;
          const canvasHeight = config.fileHeight;
          const scale = Math.min(
            canvasWidth / clip.width,
            canvasHeight / clip.height
          );
          const scaledWidth = clip.width * scale;
          const scaledHeight = clip.height * scale;
          const x = (canvasWidth - scaledWidth) / 2;
          const y = (canvasHeight - scaledHeight) / 2;

          ctx.drawImage(clip.currentFrame, x, y, scaledWidth, scaledHeight);
        } catch (error) {
          console.error('Failed to draw video frame:', error);
        }
      } else {
        // Draw placeholder if frame not loaded yet
        ctx.save();
        ctx.fillStyle = '#333333';
        ctx.fillRect(0, 0, config.fileWidth, config.fileHeight);
        ctx.fillStyle = '#ffffff';
        ctx.font = '24px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        const msg = clip.transcoding ? 'Transcoding...' : 'Loading...';
        ctx.fillText(msg, config.fileWidth / 2, config.fileHeight / 2);
        ctx.restore();
      }
    }
  }

  static fromJSON(json) {
    const videoLayer = new VideoLayer(json.idx, json.name);

    if (json.animationData) {
      videoLayer.animationData = AnimationData.fromJSON(json.animationData, videoLayer);
    }

    if (json.clips) {
      videoLayer.clips = json.clips;
    }

    if (json.linkedAudioTrack) {
      // Will be resolved after all objects are loaded
      videoLayer.linkedAudioTrack = json.linkedAudioTrack;
    }

    videoLayer.visible = json.visible;
    videoLayer.audible = json.audible;

    // Restore compression setting (default to true if not specified for backward compatibility)
    if (json.useJpegCompression !== undefined) {
      videoLayer.useJpegCompression = json.useJpegCompression;
    }

    return videoLayer;
  }

  toJSON(randomizeUuid = false) {
    return {
      type: "VideoLayer",
      idx: randomizeUuid ? uuidv4() : this.idx,
      name: randomizeUuid ? this.name + " copy" : this.name,
      visible: this.visible,
      audible: this.audible,
      animationData: this.animationData.toJSON(),
      clips: this.clips,
      linkedAudioTrack: this.linkedAudioTrack?.idx,
      useJpegCompression: this.useJpegCompression
    };
  }

  copy(idx) {
    const json = this.toJSON(true);
    json.idx = idx.slice(0, 8) + this.idx.slice(8);
    return VideoLayer.fromJSON(json);
  }

  // Compatibility methods for layer interface
  bbox() {
    return {
      x: { min: 0, max: config.fileWidth },
      y: { min: 0, max: config.fileHeight }
    };
  }
}

export { VectorLayer, AudioTrack, VideoLayer };
