import { create } from "zustand"
import * as THREE from "three"
import type { ThreeEvent } from "@react-three/fiber"
import { ClapProject, ClapSegment, ClapSegmentCategory, isValidNumber, newClap, serializeClap, ClapTracks, ClapEntity, ClapMeta } from "@aitube/clap"

import { TimelineSegment, SegmentEditionStatus, SegmentVisibility, TimelineStore, SegmentArea, SegmentPointerEvent, SegmentEventCallbackHandler, Invalidate } from "@/types/timeline"
import { getDefaultProjectState, getDefaultState } from "@/utils/getDefaultState"
import { DEFAULT_NB_TRACKS, leftBarTrackScaleWidth } from "@/constants"
import { findFreeTrack, removeFinalVideosAndConvertToTimelineSegments, clapSegmentToTimelineSegment, timelineSegmentToClapSegment } from "@/utils"
import { ClapTimelineTheme, SegmentResolver } from "@/types"
import { TimelineControlsImpl } from "@/components/controls/types"
import { TimelineCameraImpl } from "@/components/camera/types"
import { IsPlaying, JumpAt, TimelineCursorImpl, TogglePlayback } from "@/components/timeline/types"
import { computeContentSizeMetrics } from "@/compute/computeContentSizeMetrics"
import { topBarTimeScaleHeight } from "@/constants/themes"

export const useTimeline = create<TimelineStore>((set, get) => ({
  ...getDefaultState(),

  setCanvas: (canvas?: HTMLCanvasElement) => {
    set({ canvas })
  },

  clear: () => {
    // this re-initialize everything that is related to the current .clap project
    set({
      ...getDefaultProjectState()
    })
  },

  setClap: async (clap?: ClapProject) => {
    const { clear } = get()

    clear()

    if (!clap) {
      console.log(`useTimeline: no clap to show`)
      return
    }
    
    set({ isLoading: true })

    // actually you know what.. let's drop the concept of final video for the moment
    // in Clapper and the timeline
    // const finalVideo = await getFinalVideo(clap)
    const finalVideo = undefined
    
    // we remove the big/long video
    const segments = await removeFinalVideosAndConvertToTimelineSegments(clap)

    const {
      defaultCellHeight,
      durationInMsPerStep,
      cellWidth,
    } = get()

    const meta = clap.meta

    // TODO: many of those checks about average duration, nb of tracks, collisions...
    // should be done by the Clap parser and/or serializer
    // send a demand to Julian (@flngr) to get it fixed!

    let idCollisionDetector = new Set<string>()

    let tracks: ClapTracks = []

    let defaultSegmentDurationInSteps = get().defaultSegmentDurationInSteps
 
  
    for (const s of segments) {
      if (s.category === ClapSegmentCategory.CAMERA) {
        const durationInSteps = (
          (s.endTimeInMs - s.startTimeInMs) / durationInMsPerStep
        )
        // TODO: we should do this row by row
        // and look at the most recurring duration,
        // using a table
        defaultSegmentDurationInSteps = durationInSteps
        break
      }
    }

    const defaultSegmentLengthInPixels = cellWidth * defaultSegmentDurationInSteps

    // TODO: compute the exact image ratio instead of using the media orientation,
    // since it might not match the actual assets
    const defaultImageRatio = clap ? (
      (clap.meta.width || 896) / (clap.meta.height || 512)
    ) : (896 / 512)
    
    // also storyboard images and videos might have different sizes / ratios
    const defaultPreviewHeight = Math.round(
      defaultSegmentLengthInPixels / defaultImageRatio
    )

    const lineNumberToMentionedSegments: Record<number, TimelineSegment[]> = {}

    for (const segment of segments) {
      
      // TODO: move this idCollision detector into the state,
      // so that we can use it later?
      if (idCollisionDetector.has(segment.id)) {
        console.log(`collision detected! there is already a segment with id ${segment.id}`)
        continue
      }

      // --------
      const isSegmentDirectlyMentionedInTheScript = segment.category === ClapSegmentCategory.DIALOGUE || segment.category === ClapSegmentCategory.ACTION
      
      if (isSegmentDirectlyMentionedInTheScript) {
        for (let i = segment.startTimeInLines; i <= segment.endTimeInLines; i++) {
          // we only add the segment if it is not already in the map
          let existingArray: TimelineSegment[] = lineNumberToMentionedSegments[i] || []
          if (!existingArray.find(s => s.id === segment.id)) {
            existingArray.push(segment)
          }
          lineNumberToMentionedSegments[i] = existingArray
        }
      }

      idCollisionDetector.add(segment.id)

      if (!tracks[segment.track]) {
        const isPreview =
        segment.category === ClapSegmentCategory.IMAGE ||
          segment.category === ClapSegmentCategory.VIDEO

        tracks[segment.track] = {
          id: segment.track,
          // name: `Track ${s.track}`,
          name: `${segment.category}`,
          isPreview,
          height:
            isPreview
            ? defaultPreviewHeight
            : defaultCellHeight,
          hue: 0,
          occupied: true,
          visible: true,
        }
      } else {
        
        const track = tracks[segment.track]
        const categories: string[] = track.name.split(",").map((x: string) => x.trim())
        if (!categories.includes(segment.category)) {
          tracks[segment.track].name = "(misc)"

          /*
          if (categories.length < 2) {
            categories.push(s.category)
            track.name = categories.join(", ")
          } else if (!track.name.includes("..")) {
            track.name = track.name + ".."
          }
          */
        }
        
      }

    }

   // ---------- FILL-IN THE TRACKS ---------------
    for (let id = 0; id < DEFAULT_NB_TRACKS; id++) {
      if (!tracks[id]) {
        tracks[id] = {
          id,
          name: `(empty)`,
          isPreview: false,
          height: defaultCellHeight,
          hue: 0,
          occupied: false, // <-- setting this to false is the important part
          visible: true,
        }
      }
    }

    let durationInMs = clap.meta.durationInMs
    let totalNumberOfLines = clap.meta.storyPrompt.split('\n').length

    // console.log("totalNumberOfLines = " + totalNumberOfLines)

    // ---------- REPAIR THE LINE-2-SEGMENT DICTIONARY ---------------
    let previousValue: TimelineSegment[] = []
    // we aren't finished yet: the lineNumberToMentionedSegments will be missing some entries
    for (let i = 1; i <= totalNumberOfLines; i++) {
      if (!Array.isArray(lineNumberToMentionedSegments[i])) {
        lineNumberToMentionedSegments[i] = previousValue
      } else {
        previousValue = lineNumberToMentionedSegments[i]
      }
    }

    set({
      ...meta,
      scenes: clap.scenes,
      segments,
      entities: clap.entities,
      entityIndex: clap.entityIndex,
      entitiesChanged: 0,
      loadedSegments: [],
      visibleSegments: [],
      atLeastOneSegmentChanged: 1,
      allSegmentsChanged: 1,
      durationInMs,
      lineNumberToMentionedSegments,

      isLoading: false,
      finalVideo,

      ...computeContentSizeMetrics({
        width: meta.width,
        height: meta.height,
        tracks,
        cellWidth,
        defaultSegmentDurationInSteps,
        durationInMsPerStep,
        durationInMs,
      })
    })

    // one more thing: we need to call this,
    // as this will trigger various stuff in the parent
    get().jumpAt(0)
  },
  getClap: async (): Promise<ClapProject> => {
    const { 
      getClapMeta,
      entities,
      scenes,
      segments
    } = get()

    const clap = newClap({
      meta: getClapMeta(),
      entities: [...entities],
      scenes: [...scenes],
      segments: segments.map(ts => timelineSegmentToClapSegment(ts))
    })

    return clap
  },
  getClapMeta: (): ClapMeta => {
    const { 
      id,
      title,
      description,
      synopsis,
      licence,
      bpm,
      frameRate,
      tags,
      thumbnailUrl,
      imageRatio,
      durationInMs,
      width,
      height,
      imagePrompt,
      systemPrompt,
      storyPrompt,
      isLoop,
      isInteractive,
    } = get()

    return {
      id,
      title,
      description,
      synopsis,
      licence,
      bpm,
      frameRate,
      tags,
      thumbnailUrl,
      imageRatio,
      durationInMs,
      width,
      height,
      imagePrompt,
      systemPrompt,
      storyPrompt,
      isLoop,
      isInteractive,
    }
  },
  setHorizontalZoomLevel: (newHorizontalZoomLevel: number) => {
    const {
      minHorizontalZoomLevel,
      maxHorizontalZoomLevel,
      width,
      height,
      tracks,
      defaultSegmentDurationInSteps,
      durationInMsPerStep,
      cellWidth: previousCellWidth,
      durationInMs,
    } = get()
    const cellWidth = Math.min(maxHorizontalZoomLevel, Math.max(minHorizontalZoomLevel, newHorizontalZoomLevel))
    
    // nothing changed
    if (Math.round(cellWidth) === Math.round(previousCellWidth)) { return }

    const resizeStartedAt = performance.now()
    const isResizing = true

    set({
      resizeStartedAt,
      isResizing,
      ...computeContentSizeMetrics({
        width,
        height,
        tracks,
        cellWidth,
        defaultSegmentDurationInSteps,
        durationInMsPerStep,
        durationInMs,
      })
    })
  },
  
  setSegments: (segments: TimelineSegment[] = []) => {
    set({ segments, loadedSegments: [] })
  },
  setLoadedSegments: (loadedSegments: TimelineSegment[] = []) => { set({ loadedSegments }) },
  setVisibleSegments: (visibleSegments: TimelineSegment[] = []) => { set({ visibleSegments }) },

  getCellHeight: (trackNumber?: number): number => {
    const { defaultCellHeight, tracks } = get()
    const track = tracks[trackNumber!]
    return track?.height || defaultCellHeight
  },

  getVerticalCellPosition: (start: number, end: number): number => {
    const { defaultCellHeight, tracks } = get()
    let height = 0
    for (let i = start; i < end; i++) {
      const track = tracks[i!]
      height += track?.height || defaultCellHeight
    }
    return height
  },
  setHoveredSegment: ({
    segment,
    area,
  }: {
    segment?: TimelineSegment
    area?: SegmentArea 
  } = {}) => {
    const {
      invalidate,
      hoveredSegment: previousHoveredSegment,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged,
      allSegmentsChanged: previousAllSegmentsChanged,
    } = get()

    // note: we do all of this in order to avoid useless state updates
    if (segment && area) {
      if (previousHoveredSegment) {
        if (previousHoveredSegment.id === segment.id) {
          // nothing to do
          return
        } else {
          previousHoveredSegment.isHovered = false
          segment.isHoveredOnLeftHandle = false
          segment.isHoveredOnRightHandle = false
          segment.isHoveredOnBody = false
        }
      } else {
        segment.isHovered = true
        segment.isHoveredOnLeftHandle = area === SegmentArea.LEFT
        segment.isHoveredOnRightHandle = area === SegmentArea.RIGHT
        segment.isHoveredOnBody = area === SegmentArea.MIDDLE
        set({
          hoveredSegment: segment,
          allSegmentsChanged: 1 + previousAllSegmentsChanged,
          atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged
        })
      }
    } else {
      if (previousHoveredSegment) {
        previousHoveredSegment.isHovered = false
        previousHoveredSegment.isHoveredOnLeftHandle = false
        previousHoveredSegment.isHoveredOnRightHandle = false
        previousHoveredSegment.isHoveredOnBody = false
        set({
          hoveredSegment: undefined,
          allSegmentsChanged: 1 + previousAllSegmentsChanged,
          atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged
        })
      } else {
        // nothing to do
      }
    }
    // since we are un frameloop="demand" mode, we need to manual invalidate the scene
    invalidate()
  },
  setEditedSegment: ({
    segment,
    status = SegmentEditionStatus.EDITING
  }: {
    segment?: TimelineSegment
    status?: SegmentEditionStatus
  } = {
    status: SegmentEditionStatus.EDITING
  }) => {
    const {
      invalidate,
      editedSegment: previousEditedSegment,
      allSegmentsChanged: previousAllSegmentsChanged,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged
    } = get()

    // since we are un frameloop="demand" mode, we need to manual invalidate the scene
    invalidate()

    // note: we do all of this in order to avoid useless state updates
    if (segment) {
      if (previousEditedSegment) {
        if (previousEditedSegment.id === segment.id) {
          // nothing to do
          return
        } else {
          previousEditedSegment.editionStatus = SegmentEditionStatus.EDITABLE
        }
      } else {
        segment.editionStatus = status || SegmentEditionStatus.EDITING
        set({
          editedSegment: segment,
          atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged,
          allSegmentsChanged: 1 + previousAllSegmentsChanged
        })
      }
    } else {
      if (previousEditedSegment) {
        previousEditedSegment.editionStatus = SegmentEditionStatus.EDITABLE
        set({
          editedSegment: undefined,
          atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged,
          allSegmentsChanged: 1 + previousAllSegmentsChanged
        })
      } else {
        // nothing to do
      }
    }
  },
  setSelectedSegment: ({
    segment,
    isSelected,
    onlyOneSelectedAtOnce,
  }: {
    segment?: TimelineSegment
    isSelected?: boolean
    onlyOneSelectedAtOnce?: boolean
  } = {
  }) => {
    const {
      invalidate,
      segments,
      selectedSegments: previousSelectedSegments,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged,
      allSegmentsChanged: previousAllSegmentsChanged
    } = get()

    // since we are un frameloop="demand" mode, we need to manual invalidate the scene
    invalidate()

    /*
    console.log(`setSelectedSegment() called with:`, {
      segment,
    isSelected,
    onlyOneSelectedAtOnce,
    })
    */

    let newValue = typeof isSelected !== "boolean"
      ? (typeof segment?.isSelected === "boolean" ? (!segment.isSelected) : false)
      : isSelected

    // console.log('`setSelectedSegment(): new value:', newValue)

    // note: we do all of this in order to avoid useless state updates
    if (segment) {

      if (segment.isSelected === newValue) {

        // console.log('`setSelectedSegment(): nothing to do')
    
        // nothing to do
        return
      }

      let newSelectedSegments: TimelineSegment[] = previousSelectedSegments

      // if needed we clear any other selected item
      if (onlyOneSelectedAtOnce) {

        // console.log('`setSelectedSegment(): unselecting all previous segments')
    
        segments.forEach(s => {
          s.isSelected = false
        })
        newSelectedSegments = []
      }

      // console.log('`setSelectedSegment(): assigning new value and propagating changes:', newValue)
    
      segment.isSelected = newValue

      if (newValue) {
        newSelectedSegments = newSelectedSegments.concat(segment)
      } else {
        newSelectedSegments = newSelectedSegments.filter(s => s.id !== segment.id)
      }
      set({
        selectedSegments: newSelectedSegments,
        atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged,
        allSegmentsChanged: 1 + previousAllSegmentsChanged,
      })

    } else {

      // console.log('`setSelectedSegment(): mass change requested')
    
      segments.forEach(s => {
        s.isSelected = newValue
      })
      set({
        selectedSegments: isSelected ? segments : [],
        atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged,
        allSegmentsChanged: 1 + previousAllSegmentsChanged,
      })
    }
  },
  handleSegmentEvent: ({
    eventType,
    segment,
  }: {
    eventType: SegmentPointerEvent
    segment: TimelineSegment
  }): SegmentEventCallbackHandler => {
    function segmentEventCallbackHandler(event: ThreeEvent<PointerEvent> | ThreeEvent<MouseEvent>) {
      const pointX = event.point.x
      const offsetX = event.offsetX
      const offsetY = event.offsetY

      /*
        console.log("segmentEventCallbackHandler:" + JSON.stringify({
          pointX: Math.round(e.point.x),
          offsetX: Math.round(e.offsetX),
          offsetY: Math.round(e.offsetY),
          isOutOfRange,
          cursorLeftPosInPx: Math.round(cursorLeftPosInPx),
          cursorRightPosInPx: Math.round(cursorRightPosInPx),
          area
        }, null, 2))
      */

      const { cellWidth, containerWidth, durationInMsPerStep, setSelectedSegment, setHoveredSegment, setEditedSegment } = get()

      const durationInSteps = (
        (segment.endTimeInMs - segment.startTimeInMs) / durationInMsPerStep
      )

      /*
      const startTimeInSteps = (
        segment.startTimeInMs / durationInMsPerStep
      )
      */

      const widthInPx = durationInSteps * cellWidth

      const segmentWidth = widthInPx

      const isOutOfRange = offsetX < leftBarTrackScaleWidth || offsetY < topBarTimeScaleHeight
    
      const cursorX = pointX + (containerWidth / 2)
      const cursorTimestampAtInMs = (cursorX / cellWidth) * useTimeline.getState().durationInMsPerStep
      
      //console.log("cells.Cell:onClick() e:", e)
    
      const wMin = cursorTimestampAtInMs - segment.startTimeInMs
      const wMax = segment.endTimeInMs - segment.startTimeInMs
      const cursorLeftPosInRatio = wMin / wMax
    
      const cursorLeftPosInPx = cursorLeftPosInRatio * segmentWidth
      const cursorRightPosInPx = segmentWidth - cursorLeftPosInPx 
    
      // note: this should be "responsive", with a max width
      const sideGrabHandleWidth = 9
      // let isInLeftArea = cursorLeftPosInRatio < 0.5
      // let isInRightArea = cursorLeftPosInRatio > 0.5
    
      const area =
        (cursorLeftPosInPx < sideGrabHandleWidth) ? SegmentArea.LEFT
      : (cursorRightPosInPx < sideGrabHandleWidth) ? SegmentArea.RIGHT
      : SegmentArea.MIDDLE

      if (isOutOfRange) {
        event.stopPropagation()
        return false
      }

      if (eventType === SegmentPointerEvent.DOUBLE_CLICK) {
        setHoveredSegment({
          segment,
          area
        })
        setSelectedSegment({
          segment,
    
          // we leave it unspecified to create an automated toggle
          // isSelected: true,
    
          onlyOneSelectedAtOnce: true,
        })
        setEditedSegment({
          segment,
          status: SegmentEditionStatus.EDITING
        })
      } else if (
        eventType === SegmentPointerEvent.CLICK ||
        eventType === SegmentPointerEvent.DOWN ||
        eventType === SegmentPointerEvent.MOVE
      ) {
        setHoveredSegment({
          segment,
          area
        })
        if (area === SegmentArea.LEFT) {
          setEditedSegment({
            segment,
            status: SegmentEditionStatus.RESIZE_START
          })
        } else if (area === SegmentArea.RIGHT) {
          setEditedSegment({
            segment,
            status: SegmentEditionStatus.RESIZE_END
          })
        } else if (area === SegmentArea.MIDDLE) {
          setEditedSegment({
          segment,
            status: SegmentEditionStatus.DRAGGING
          })
        }
      } else if (eventType === SegmentPointerEvent.UP) {
        setHoveredSegment({
          segment: undefined,
          area
        })
        setEditedSegment({
          segment: undefined,
        })
      }

      event.stopPropagation()
      return false
    }
    return segmentEventCallbackHandler
  },
  trackSilentChangeInSegment: (segmentId: string) => {
    const { silentChangesInSegment, atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged } = get()
    set({
      silentChangesInSegment: Object.assign(silentChangesInSegment, {
        [segmentId]: 1 + (silentChangesInSegment[segmentId] || 0)
      }),
      atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged,
    })
  },
  trackSilentChangeInSegments: (segmentIds: string[]) => {
    const { silentChangesInSegment, atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged } = get()
    
    for (const id of segmentIds) {
      silentChangesInSegment[id] = 1 + (silentChangesInSegment[id] || 0)
    }
    set({
      silentChangesInSegment,
      atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged,
    })
  },
  setTimelineTheme: (theme: ClapTimelineTheme) => {
    set({ theme })
  },
  setTimelineCamera: (timelineCamera?: TimelineCameraImpl) => {
    set({ timelineCamera })
  },
  setTimelineControls: (timelineControls?: TimelineControlsImpl) => {
    set({ timelineControls })
  },
  setTopBarTimeScale: (topBarTimeScale?: THREE.Group<THREE.Object3DEventMap>) => {
    set({ topBarTimeScale })
  },
  setLeftBarTrackScale: (leftBarTrackScale?: THREE.Group<THREE.Object3DEventMap>) => {
    set({ leftBarTrackScale })
  },
  // used when we move the full-length scroller
  setScrollX: (scrollX: number) => {
    set({ scrollX })
  },
  handleMouseWheel: ({ deltaX, deltaY }: { deltaX: number, deltaY: number }) => {
    const { scrollX, scrollY } = get()
    // TODO: compute the limits here, to avoid doing re-renderings for nothing
    set({
      scrollX: scrollX + deltaX,
      scrollY: scrollY - deltaY,
    })
  },
  toggleTrackVisibility: (trackId: number) => {
    const {
      width,
      height,
      tracks,
      cellWidth,
      defaultSegmentDurationInSteps,
      durationInMsPerStep,
      durationInMs,
    } = get()

    set({
      ...computeContentSizeMetrics({
        width,
        height,
        tracks: tracks.map((t: any) => (
          t.id === trackId
          ? { ...t, visible: !t.visible }
          : t
        )),
        cellWidth,
        defaultSegmentDurationInSteps,
        durationInMsPerStep,
        durationInMs,
      })
    })
  },
  setContainerSize: ({ width, height }: { width: number; height: number }) => {
    const { containerWidth: previousWidth, containerHeight: previousHeight } = get()
    const changed = 
      (Math.round(previousWidth) !== Math.round(height))
      || (Math.round(previousHeight) !== Math.round(height))
    if (!changed) { return }

    set({
      containerWidth: width,
      containerHeight: height,
      resizeStartedAt: performance.now(),
      isResizing: true,
      /*

      changing the *container* size has absolutely no impact on the content
      
      ...computeContentSizeMetrics({
        clap,
        tracks,
        cellWidth,
        defaultSegmentDurationInSteps
      })
       */
    })
  },
  setTimelineCursor: (timelineCursor?: TimelineCursorImpl) => {
    set({ timelineCursor })
  },
  setIsDraggingCursor: (isDraggingCursor: boolean) => {
    set({ isDraggingCursor })
  },
  setCursorTimestampAtInMs: (cursorTimestampAtInMs: number = 0) => {
    const { invalidate, cursorTimestampAtInMs: previousCursorTimestampAtInMs } = get()
    if (cursorTimestampAtInMs !== previousCursorTimestampAtInMs) {
      set({ cursorTimestampAtInMs })
      invalidate()
    }
  },
  setJumpAt: (jumpAt: JumpAt) => {
    set({ jumpAt })
  },
  setIsPlaying: (isPlaying: IsPlaying) => {
    set({ isPlaying })
  },
  setTogglePlayback: (togglePlayback: TogglePlayback) => {
    set({ togglePlayback })
  },
  // this function has an issue, it saves stuff as .txt, which is bad
  saveClapAs: async ({
    embedded,

    saveToFilePath,

    showTargetDirPopup = false,

    // some extra text to append to the file name
    extraLabel = ""
  }: {
    // if embedded is true, the file will be larger, as all the content,
    // image, video, audio..
    // will be embedded into it (except the last big video)
    embedded?: boolean

    saveToFilePath?: string

    // note: the native select picker doesn't work in all browsers (eg. not in Firefox)
    // but it's not an issue, in our case we can save using Node/Electron + the cloud
    showTargetDirPopup?: boolean

    // some extra text to append to the file name
    extraLabel?: string
  } = {}) => {
    const { getClap } = get()

    const clap = await getClap()

    const blob = await serializeClap(clap)

    // Create an object URL for the compressed clap blob
    const objectUrl = URL.createObjectURL(blob);
  
    // Create an anchor element and force browser download
    const anchor = document.createElement("a");
    anchor.href = objectUrl;
    anchor.download = saveToFilePath || `${clap.meta.title}${extraLabel}.clap`;
    document.body.appendChild(anchor); // Append to the body (could be removed once clicked)
    anchor.click(); // Trigger the download
  
    // Cleanup: revoke the object URL and remove the anchor element
    URL.revokeObjectURL(objectUrl);
    document.body.removeChild(anchor);

    return objectUrl.length
  },
  setSegmentResolver: (segmentResolver: SegmentResolver) => {
    set({ segmentResolver })
  },
  resolveSegment: async (segment: TimelineSegment): Promise<TimelineSegment> => {
    const { segmentResolver, fitSegmentToAssetDuration } = get()
    if (!segmentResolver) { return segment }
    
    segment = await segmentResolver(segment)

    // after a segment has ben resolved, it is possible that the size
    // of its asset changed (eg. a dialogue line longer than the segment's length)
    //
    // there are multiple ways to solve this, one approach could be to 
    // just add some more B-roll (more shots)
    //
    // or we can also extend it, which is the current simple solution
    //
    // for the other categories, such as MUSIC or SOUND,
    // we assume it is okay if they are too short or too long,
    // and that we can crop them etc
    //
    // note that video clips are also concerned: we want them to perfectly fit
    if (segment.category === ClapSegmentCategory.DIALOGUE) {
      await fitSegmentToAssetDuration(segment)
    } else if (segment.category === ClapSegmentCategory.VIDEO) {
      await fitSegmentToAssetDuration(segment)
    }

    return segment
  },
  addSegments: async ({
    segments = [],
    startTimeInMs,
    track
  }: {
    segments?: TimelineSegment[]
    startTimeInMs?: number
    track?: number
  }): Promise<void> => {
    if (segments?.length) {
      const { addSegment } = get()
      for (const segment of segments) {
        await addSegment({
          segment,
          startTimeInMs,
          track
        })
      }
    }
  },
  assignTrack: async ({
    segment,
    track,
    triggerChange,
  }: {
    segment: ClapSegment
    track: number
    triggerChange?: boolean
  }): Promise<void> => {
    const {
      width,
      height,
      cellWidth,
      defaultSegmentDurationInSteps,
      durationInMsPerStep,
      durationInMs,
      tracks,
      defaultPreviewHeight,
      defaultCellHeight,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged,
      allSegmentsChanged: previousAllSegmentsChanged,
    } = get()

    segment.track = track
   
    let nbTracks = tracks.length

    // add the track if it is missing
    if (!tracks[segment.track]) {
      const isPreview =
        segment.category === ClapSegmentCategory.IMAGE ||
        segment.category === ClapSegmentCategory.VIDEO
   
      tracks[segment.track] = {
        id: segment.track,
        // name: `Track ${s.track}`,
        name: `${segment.category}`,
        isPreview,
        height:
          isPreview
          ? defaultPreviewHeight
          : defaultCellHeight,
        hue: 0,
        occupied: true,
        visible: true,
      }
    }

    if (triggerChange) {
      set({
        allSegmentsChanged: previousAllSegmentsChanged + 1,
        atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged + 1,
        ...computeContentSizeMetrics({
          width,
          height,
          tracks,
          cellWidth,
          defaultSegmentDurationInSteps,
          durationInMsPerStep,
          durationInMs,
        }),
      })
    }
  },
  addSegment: async ({
    segment,
    startTimeInMs: requestedStartTimeInMs,
    track: requestedTrack
  }: {
    segment: TimelineSegment
    startTimeInMs?: number
    track?: number
  }): Promise<void> => {
    // adding a segment is a bit complicated, lot of stuff might have to be updated
    const {
      width,
      height,
      findFreeTrack,
      cellWidth,
      tracks,
      segments: previousSegments,
      allSegmentsChanged: previousAllSegmentsChanged,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged,
      durationInMs: previousDurationInMs,
      defaultSegmentDurationInSteps,
      durationInMsPerStep,
      defaultPreviewHeight,
      defaultCellHeight,
      assignTrack,
    } = get()


    // note: the requestedTrack might not be empty
    const segmentDuration = segment.endTimeInMs - segment.startTimeInMs

    const startTimeInMs = isValidNumber(requestedStartTimeInMs) ? requestedStartTimeInMs! : segment.startTimeInMs

    const endTimeInMs = startTimeInMs + segmentDuration

    // for now let's do something simple: to always search for an available track
    const availableTrack = isValidNumber(requestedTrack) ? requestedTrack! : findFreeTrack({
      startTimeInMs,
      endTimeInMs
    })

    /*
    console.log("availableTrack:", {
      requestedStartTimeInMs,
      segmentDuration,
      availableTrack,
      requestedTrack,
      startTimeInMs,
      endTimeInMs
    })
      */

    // we just make sure to sanitize it before adding it
    segment = await clapSegmentToTimelineSegment(segment)

    // also, we assume that we are adding a segment in a place where it's visible
    // (if we are wrong don't worry, our visibility detector will fix it anyway)
    segment.visibility = SegmentVisibility.VISIBLE
    
    assignTrack({
      segment,
      track: availableTrack,

      // we don't want to trigger a state change just yet
      triggerChange: false,
    })


    // we assume that the provided segment is valid, with a unique UUID
  
    // then we need to update everything

    // ok so, I'm not a big fan of doing this,
    // officially the order doesn't matter in the previousSegments array
    // this means we don't have FOR LOOPs with a BREAK etc 
    // still, I think we can improve our performance one day by storing them
    // on a temporally sorted tree
    const segments = previousSegments.concat(segment)

    const durationInMs =
      segment.endTimeInMs > previousDurationInMs
      ? segment.endTimeInMs
      : previousDurationInMs

    set({
      segments,
      allSegmentsChanged: previousAllSegmentsChanged + 1,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged + 1,
      durationInMs,
      ...computeContentSizeMetrics({
        width,
        height,
        tracks,
        cellWidth,
        defaultSegmentDurationInSteps,
        durationInMsPerStep,
        durationInMs,
      })
    })
  },
  findFreeTrack: ({
    startTimeInMs,
    endTimeInMs
  }: {
    startTimeInMs?: number
    endTimeInMs?: number
  }): number => {
    const { segments } = get()
    return findFreeTrack({ segments, startTimeInMs, endTimeInMs })
  },

  // resize and move the end of a segment, as well as the segment after it
  fitSegmentToAssetDuration: async (segment: TimelineSegment, requestedDurationInMs?: number): Promise<void> => {
    
    const {
      width,
      height,
      tracks,
      cellWidth,
      defaultSegmentDurationInSteps,
      segments,
      durationInMsPerStep,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged,
      allSegmentsChanged: previousAllSegmentsChanged,
      durationInMs: previousDurationInMs,
      findFreeTrack,
      assignTrack
    } = get()

    let requestedDuration: number =
      typeof requestedDurationInMs === "number" && isFinite(requestedDurationInMs) && !isNaN(requestedDurationInMs)
      ? requestedDurationInMs
      : segment.assetDurationInMs


    // trivial case: nothing to do!
    const segmentDurationInMs = segment.endTimeInMs - segment.startTimeInMs
    if (
      requestedDuration === 0
      ||
      segmentDurationInMs === requestedDuration
    ) {
      return
    }

    // let's set some limits eg. at least 1 sec, I think this is reasonable
    const minimumLengthInSteps = 2
    const minimumLengthInMs = minimumLengthInSteps * durationInMsPerStep

    // positive if new duration is longer,
    // negative if shorter
    const timeDifferenceInMs = requestedDuration - segmentDurationInMs

    // setup some limits
    const newSegmentDurationInMs = Math.max(
      minimumLengthInMs,
      segmentDurationInMs + timeDifferenceInMs
    )

    // ok, well, there is nothing to change actually
    if (segmentDurationInMs === newSegmentDurationInMs) { return }


    // positive if new duration is longer,
    // negative if shorter
    const newTimeDifferenceInMs = newSegmentDurationInMs - segmentDurationInMs

    const startTimeInMs = segment.startTimeInMs
    const endTimeInMs = segment.endTimeInMs
    // const newEndTimeInMs = endTimeInMs + newTimeDifferenceInMs

    let durationInMs = previousDurationInMs

    const referenceSegmentIsMusicOrSound =
      segment.category === ClapSegmentCategory.MUSIC
      || segment.category === ClapSegmentCategory.SOUND

    let segmentsToDelete: string[] = []

    for (const s of segments) {
      // our strategy will be different depending on the type of segment
      // basically, if it's a sound or a music, we don't need to cut the segment,
      // and we don't have to extend the current shot.
      // instead, we can let it go outerbound, although this creates 2 problems:
      // 1. overlapping with another music/sound (on the same track or not)
      //    -> fix is easy, we can resize or delete completely the other one
      // 2. collision with an item on the same track (eg. of a different type)
      //    -> fix is annoying, for now the a quick solution is to put the segment
      //      onto its own free track
      const currentSegmentIsMusicOrSound =
        s.category === ClapSegmentCategory.MUSIC
        || s.category === ClapSegmentCategory.SOUND

      const isSameCategoryAsReferenceSegment = s.category === segment.category

      const isSamePromptAsReferenceSegment = s.prompt === segment.prompt


      if (referenceSegmentIsMusicOrSound) {
        
        if (isSameCategoryAsReferenceSegment && isSamePromptAsReferenceSegment) {
          if (s.endTimeInMs <= endTimeInMs) { 
            // we delete
            console.log("TODO JULIAN: DELETE SEGMENT", s)
            // segmentsToDelete.push(s.id)
            // note: 
          } else if (s.startTimeInMs < endTimeInMs) { 
            // we resize
            console.log("TODO JULIAN: resize segment")
            // s.startTimeInMs = endTimeInMs
          }
        }
        // independently, we run our collision detector
        // it is important at this stage to take into account any change,
        // eg. if we've already deleted segment `s` there is no need to
        // assign a new free track
        const isSameTrackAsReferenceSegment = s.track === segment.track

        if (isSameTrackAsReferenceSegment) {
          // if we have a collision, there is currently no way around it we need to create a new track
          if (!(s.endTimeInMs <= startTimeInMs || s.startTimeInMs >= endTimeInMs)) {
            const newTrack = findFreeTrack({ startTimeInMs, endTimeInMs })
            //console.log(`ASSIGN NEW TRACK (${newTrack}) TO SEGMENT`, s)
     
            assignTrack({
              segment,
              track: newTrack,

              // we don't want to trigger a state change
              triggerChange: false,
            })
            
          }
      
        }
      } else {
        // this is a dialogue or a video, we can apply our regular strategy
        if (endTimeInMs <= s.startTimeInMs) {
          s.startTimeInMs += newTimeDifferenceInMs
        }
        if (endTimeInMs <= s.endTimeInMs) {
          s.endTimeInMs += newTimeDifferenceInMs
        }

        // also need to update the total duration
        if (s.endTimeInMs > durationInMs) {
          durationInMs = s.endTimeInMs
        }
      }
    }
    //console.log(`TODO Julian: stretched segments (overlapping segments) should be re-generated`)

    set({
      segments,
      allSegmentsChanged: previousAllSegmentsChanged + 1,
      atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged + 1,
      durationInMs,
      ...computeContentSizeMetrics({
        width,
        height,
        tracks,
        cellWidth,
        defaultSegmentDurationInSteps,
        durationInMsPerStep,
        durationInMs,
      })
    })
  },
  deleteSegments: (ids: string[]): void => {
    const {
      segments: previousSegments,
      allSegmentsChanged,
      atLeastOneSegmentChanged,
      silentChangesInSegment,
    } = get()

    const deletables = new Set(ids)

    const newSegments = previousSegments.filter(({ id }) => {
      silentChangesInSegment[id] = 1 + (silentChangesInSegment[id] || 0)
      return !deletables.has(id)
    })

    set({
      segments: newSegments,
      allSegmentsChanged: 1 + allSegmentsChanged,
      atLeastOneSegmentChanged: 1 + atLeastOneSegmentChanged,
      silentChangesInSegment,
    })
  },
  addEntities: async (newEntities: ClapEntity[]) => {
    const {
      entities: previousEntities,
      entityIndex: previousentityIndex,
      entitiesChanged: previousEntitiesChanged,
    } = get()


    let somethingChanged = false

    for (const newEntity of newEntities) {
      if (previousentityIndex[newEntity.id]) {
        // entity already exists
        continue
      }
      previousEntities.push(newEntity)
      previousentityIndex[newEntity.id] = newEntity
      somethingChanged = true
    }

    if (somethingChanged) {
      set({
        entities: previousEntities,
        entityIndex: previousentityIndex,
        entitiesChanged: previousEntitiesChanged + 1,
      })
    }
  },
  updateEntities: async (newEntities: ClapEntity[]) => {
    const {
      entities: previousEntities,
      entityIndex: previousentityIndex,
      entitiesChanged: previousEntitiesChanged,
    } = get()

    let somethingChanged = false
    for (const newEntity of newEntities) {
      const entity = previousentityIndex[newEntity.id]
      if (!entity) {
        // entity doesn't exist
        continue
      }
      Object.assign(entity, newEntity)

      // to optimize things, we could check if the assign really did change something
      somethingChanged = true
    }

    if (somethingChanged) {
      set({
        entities: previousEntities,
        entityIndex: previousentityIndex,
        entitiesChanged: previousEntitiesChanged + 1,
      })
    }
  },
  deleteEntities: async (entitiesToDelete: (ClapEntity|string)[]) => {
    const {
      entities: previousEntities,
      entityIndex: previousentityIndex,
      entitiesChanged: previousEntitiesChanged,
    } = get()

    let idsToDelete: string[] = []
  
    for (const newEntityOrId of entitiesToDelete) {
      const id = typeof newEntityOrId === "string" ? newEntityOrId : newEntityOrId.id
      delete previousentityIndex[id]
      idsToDelete.push(id)
    }

    if (idsToDelete.length) {
      set({
        entities: previousEntities.filter(e => !idsToDelete.includes(e.id)),
        entityIndex: previousentityIndex,
        entitiesChanged: previousEntitiesChanged + 1,
      })
    }
  },

  setInvalidate: (invalidate?: Invalidate) => {
    set({ invalidate: invalidate || (() => {}) })
  }
}
))

if (typeof window !== 'undefined') {
  (window as any).useTimeline = useTimeline
}
