export type DocsGraphicType = "image" | "shape" | "gradient" | "draw"

export type DocsGradientType = "linear" | "radial"

export type DocsGraphicPlacement = "inline" | "block" | "absolute"

export type DocsGraphicPositionMode = "move-with-text" | "fixed-on-page"

/** Text wrap / layering relative to body text */
export type DocsGraphicWrap =
  | "inline"
  | "square"
  | "tight"
  | "through"
  | "top-bottom"
  | "behind"
  | "in-front"

export type DocsGraphicFloatSide = "left" | "right" | "center"

export type DocsShapeType = "rect" | "ellipse" | "line" | "arrow"

export type DocsCropShape = "rect" | "ellipse"

export type DocsImageFit = "contain" | "cover" | "crop"

export type DocsImageLayoutFit = "contain" | "cover"

export type DocsImageFitAnchor = 0 | 0.5 | 1

export type DocsGraphicAttrs = {
  graphicType: DocsGraphicType
  src: string | null
  alt: string
  assetId: string | null
  shapeType: DocsShapeType
  fill: string
  stroke: string
  strokeWidth: number
  gradientCss: string
  gradientType: DocsGradientType
  gradientAngle: number
  gradientColor1: string
  gradientColor2: string
  width: number
  height: number
  placement: DocsGraphicPlacement
  wrap: DocsGraphicWrap
  floatSide: DocsGraphicFloatSide
  x: number
  y: number
  positionMode: DocsGraphicPositionMode
  /** ProseMirror doc position; -1 when unset */
  anchorPos: number
  pageIndex: number
  pageX: number
  pageY: number
  wrapMarginMm: number
  rotationDeg: number
  zIndex: number
  /** Normalized crop region 0–1 relative to source image */
  cropX: number
  cropY: number
  cropWidth: number
  cropHeight: number
  cropShape: DocsCropShape
  /** When true, corner resize keeps width/height ratio (images). */
  lockAspectRatio: boolean
  /** How the image fits its frame when no crop transform is applied. */
  imageFit: DocsImageFit
  /** Horizontal focal point for contain/cover (0 = left, 0.5 = center, 1 = right). */
  imageFitAnchorH: DocsImageFitAnchor
  /** Vertical focal point for contain/cover (0 = top, 0.5 = center, 1 = bottom). */
  imageFitAnchorV: DocsImageFitAnchor
  opacity: number
  shadow: string
  /** -1..1, 0 = neutral */
  brightness: number
  /** -1..1, 0 = neutral */
  contrast: number
  /** Recolor preset id, "" = none */
  recolor: string
  altTitle: string
  /** Serialized Excalidraw scene JSON for re-editing vector drawings. */
  drawScene: string | null
  /** UltiDraw file id when the drawing is linked to Drive (edit opens UltiDraw). */
  drawDriveFileId: string | null
}

export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = {
  graphicType: "image",
  src: null,
  alt: "",
  assetId: null,
  shapeType: "rect",
  fill: "#4285f4",
  stroke: "#1a73e8",
  strokeWidth: 2,
  gradientCss: "",
  gradientType: "linear",
  gradientAngle: 180,
  gradientColor1: "#4285f4",
  gradientColor2: "#34a853",
  width: 240,
  height: 160,
  placement: "block",
  wrap: "square",
  floatSide: "left",
  x: 0,
  y: 0,
  positionMode: "move-with-text",
  anchorPos: -1,
  pageIndex: 0,
  pageX: 0,
  pageY: 0,
  wrapMarginMm: 3,
  rotationDeg: 0,
  zIndex: 0,
  cropX: 0,
  cropY: 0,
  cropWidth: 1,
  cropHeight: 1,
  cropShape: "rect",
  lockAspectRatio: true,
  imageFit: "contain",
  imageFitAnchorH: 0.5,
  imageFitAnchorV: 0.5,
  opacity: 1,
  shadow: "",
  brightness: 0,
  contrast: 0,
  recolor: "",
  altTitle: "",
  drawScene: null,
  drawDriveFileId: null,
}

export type DocsGraphicRecolorPreset = {
  id: string
  label: string
  /** CSS filter chain approximating the Google Docs recolor tint */
  filter: string
}

export const DOCS_GRAPHIC_RECOLOR_PRESETS: DocsGraphicRecolorPreset[] = [
  { id: "", label: "Aucune recolorisation", filter: "" },
  { id: "grayscale", label: "Niveaux de gris", filter: "grayscale(1)" },
  { id: "sepia", label: "Sépia", filter: "sepia(1)" },
  { id: "washout", label: "Délavé", filter: "grayscale(0.4) brightness(1.4) saturate(0.6)" },
  { id: "negative", label: "Négatif", filter: "invert(1)" },
  { id: "blue-light", label: "Bleu clair", filter: "grayscale(1) sepia(1) hue-rotate(175deg) saturate(2.4) brightness(1.15)" },
  { id: "blue-dark", label: "Bleu foncé", filter: "grayscale(1) sepia(1) hue-rotate(185deg) saturate(3.2) brightness(0.75)" },
  { id: "teal", label: "Bleu canard", filter: "grayscale(1) sepia(1) hue-rotate(130deg) saturate(2.2)" },
  { id: "green", label: "Vert", filter: "grayscale(1) sepia(1) hue-rotate(60deg) saturate(2.4)" },
  { id: "yellow", label: "Jaune", filter: "grayscale(1) sepia(1) hue-rotate(10deg) saturate(2.8) brightness(1.1)" },
  { id: "orange", label: "Orange", filter: "grayscale(1) sepia(1) hue-rotate(-15deg) saturate(3)" },
  { id: "red", label: "Rouge", filter: "grayscale(1) sepia(1) hue-rotate(-45deg) saturate(3.4)" },
  { id: "purple", label: "Violet", filter: "grayscale(1) sepia(1) hue-rotate(220deg) saturate(2.4)" },
]

/** CSS filter string combining recolor preset + brightness/contrast. */
export function computeGraphicFilterCss(
  attrs: Pick<DocsGraphicAttrs, "brightness" | "contrast" | "recolor">
): string {
  const parts: string[] = []
  const preset = DOCS_GRAPHIC_RECOLOR_PRESETS.find((p) => p.id === attrs.recolor)
  if (preset?.filter) parts.push(preset.filter)
  if (attrs.brightness !== 0) {
    parts.push(`brightness(${clampRange(1 + attrs.brightness, 0, 2)})`)
  }
  if (attrs.contrast !== 0) {
    parts.push(`contrast(${clampRange(1 + attrs.contrast, 0, 2)})`)
  }
  return parts.join(" ")
}

function clampRange(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value))
}

export const DOCS_GRAPHIC_WRAP_LABELS: Record<DocsGraphicWrap, string> = {
  inline: "En ligne avec le texte",
  square: "Carré",
  tight: "Rapproché",
  through: "À travers",
  "top-bottom": "Haut et bas",
  behind: "Derrière le texte",
  "in-front": "Devant le texte",
}

export const DOCS_GRAPHIC_PLACEMENT_LABELS: Record<DocsGraphicPlacement, string> = {
  inline: "En ligne",
  block: "Bloc",
  absolute: "Position absolue",
}

export const DOCS_GRAPHIC_POSITION_MODE_LABELS: Record<DocsGraphicPositionMode, string> = {
  "move-with-text": "Déplacer avec le texte",
  "fixed-on-page": "Position fixe sur la page",
}

export function buildGradientCss(
  angle: number,
  color1: string,
  color2: string,
  type: DocsGradientType = "linear"
): string {
  if (type === "radial") {
    return `radial-gradient(circle, ${color1}, ${color2})`
  }
  return `linear-gradient(${angle}deg, ${color1}, ${color2})`
}

export function parseGraphicAttrs(raw: Record<string, unknown>): DocsGraphicAttrs {
  const num = (key: keyof DocsGraphicAttrs, fallback: number) => {
    const value = raw[key]
    return typeof value === "number" && Number.isFinite(value) ? value : fallback
  }
  const str = (key: keyof DocsGraphicAttrs, fallback: string) => {
    const value = raw[key]
    return typeof value === "string" ? value : fallback
  }

  const gradientAngle = num("gradientAngle", DOCS_GRAPHIC_DEFAULTS.gradientAngle)
  const gradientColor1 = str("gradientColor1", DOCS_GRAPHIC_DEFAULTS.gradientColor1)
  const gradientColor2 = str("gradientColor2", DOCS_GRAPHIC_DEFAULTS.gradientColor2)
  const gradientCssRaw = str("gradientCss", "")
  const gradientType: DocsGradientType =
    raw.gradientType === "radial" || raw.gradientType === "linear"
      ? raw.gradientType
      : gradientCssRaw.includes("radial-gradient")
        ? "radial"
        : DOCS_GRAPHIC_DEFAULTS.gradientType
  const gradientCss =
    gradientCssRaw ||
    (raw.graphicType === "gradient"
      ? buildGradientCss(gradientAngle, gradientColor1, gradientColor2, gradientType)
      : "")

  // Absolute placement and behind/in-front wraps always live on the page layer.
  const forcedFixed =
    raw.placement === "absolute" || raw.wrap === "behind" || raw.wrap === "in-front"
  const positionMode: DocsGraphicPositionMode = forcedFixed
    ? "fixed-on-page"
    : raw.positionMode === "fixed-on-page" || raw.positionMode === "move-with-text"
      ? raw.positionMode
      : DOCS_GRAPHIC_DEFAULTS.positionMode

  const anchorRaw = raw.anchorPos
  const anchorPos =
    typeof anchorRaw === "number" && Number.isFinite(anchorRaw) ? anchorRaw : -1

  const legacyX = num("x", 0)
  const legacyY = num("y", 0)
  // Node attrs always carry pageX/pageY defaults (0), so presence alone can't
  // signal migration: treat (0,0) page coords with non-zero legacy x/y as
  // unmigrated legacy positioning.
  const rawPageX = typeof raw.pageX === "number" && Number.isFinite(raw.pageX) ? raw.pageX : null
  const rawPageY = typeof raw.pageY === "number" && Number.isFinite(raw.pageY) ? raw.pageY : null
  const pageCoordsProvided =
    (rawPageX != null && rawPageX !== 0) || (rawPageY != null && rawPageY !== 0)
  const pageX = pageCoordsProvided ? (rawPageX ?? 0) : legacyX || (rawPageX ?? 0)
  const pageY = pageCoordsProvided ? (rawPageY ?? 0) : legacyY || (rawPageY ?? 0)

  return {
    graphicType:
      raw.graphicType === "shape" ||
      raw.graphicType === "gradient" ||
      raw.graphicType === "image" ||
      raw.graphicType === "draw"
        ? raw.graphicType
        : DOCS_GRAPHIC_DEFAULTS.graphicType,
    src: typeof raw.src === "string" ? raw.src : null,
    alt: str("alt", ""),
    shapeType:
      raw.shapeType === "ellipse" ||
      raw.shapeType === "line" ||
      raw.shapeType === "arrow" ||
      raw.shapeType === "rect"
        ? raw.shapeType
        : DOCS_GRAPHIC_DEFAULTS.shapeType,
    fill: str("fill", DOCS_GRAPHIC_DEFAULTS.fill),
    stroke: str("stroke", DOCS_GRAPHIC_DEFAULTS.stroke),
    strokeWidth: num("strokeWidth", DOCS_GRAPHIC_DEFAULTS.strokeWidth),
    gradientCss,
    gradientType,
    gradientAngle,
    gradientColor1,
    gradientColor2,
    width: Math.max(24, num("width", DOCS_GRAPHIC_DEFAULTS.width)),
    height: Math.max(24, num("height", DOCS_GRAPHIC_DEFAULTS.height)),
    placement:
      raw.placement === "inline" || raw.placement === "block" || raw.placement === "absolute"
        ? raw.placement
        : DOCS_GRAPHIC_DEFAULTS.placement,
    wrap:
      raw.wrap === "inline" ||
      raw.wrap === "square" ||
      raw.wrap === "tight" ||
      raw.wrap === "through" ||
      raw.wrap === "top-bottom" ||
      raw.wrap === "behind" ||
      raw.wrap === "in-front"
        ? raw.wrap
        : DOCS_GRAPHIC_DEFAULTS.wrap,
    floatSide:
      raw.floatSide === "left" || raw.floatSide === "right" || raw.floatSide === "center"
        ? raw.floatSide
        : DOCS_GRAPHIC_DEFAULTS.floatSide,
    assetId: typeof raw.assetId === "string" && raw.assetId ? raw.assetId : null,
    x: legacyX,
    y: legacyY,
    positionMode,
    anchorPos,
    pageIndex: Math.max(0, num("pageIndex", 0)),
    pageX,
    pageY,
    wrapMarginMm: Math.max(0, num("wrapMarginMm", DOCS_GRAPHIC_DEFAULTS.wrapMarginMm)),
    rotationDeg: num("rotationDeg", 0),
    zIndex: num("zIndex", 0),
    cropX: clamp01(num("cropX", 0)),
    cropY: clamp01(num("cropY", 0)),
    cropWidth: clamp01(num("cropWidth", 1), 1),
    cropHeight: clamp01(num("cropHeight", 1), 1),
    cropShape: raw.cropShape === "ellipse" ? "ellipse" : "rect",
    lockAspectRatio: raw.lockAspectRatio === false ? false : true,
    imageFit:
      raw.imageFit === "cover" || raw.imageFit === "crop" ? raw.imageFit : "contain",
    imageFitAnchorH: parseImageFitAnchor(raw.imageFitAnchorH, DOCS_GRAPHIC_DEFAULTS.imageFitAnchorH),
    imageFitAnchorV: parseImageFitAnchor(raw.imageFitAnchorV, DOCS_GRAPHIC_DEFAULTS.imageFitAnchorV),
    opacity: clamp01(num("opacity", 1), 1),
    shadow: str("shadow", ""),
    brightness: clampSigned(num("brightness", 0)),
    contrast: clampSigned(num("contrast", 0)),
    recolor: str("recolor", ""),
    altTitle: str("altTitle", ""),
    drawScene: typeof raw.drawScene === "string" && raw.drawScene ? raw.drawScene : null,
    drawDriveFileId:
      typeof raw.drawDriveFileId === "string" && raw.drawDriveFileId
        ? raw.drawDriveFileId
        : null,
  }
}

function clampSigned(value: number): number {
  if (!Number.isFinite(value)) return 0
  return Math.min(1, Math.max(-1, value))
}

function clamp01(value: number, fallback = 0): number {
  if (!Number.isFinite(value)) return fallback
  return Math.min(1, Math.max(0, value))
}

function parseImageFitAnchor(value: unknown, fallback: DocsImageFitAnchor): DocsImageFitAnchor {
  if (value === 0 || value === 0.5 || value === 1) return value
  if (typeof value === "number" && Number.isFinite(value)) {
    if (value <= 0.25) return 0
    if (value >= 0.75) return 1
    return 0.5
  }
  return fallback
}

function anchorOffset(anchor: DocsImageFitAnchor, frameSize: number, contentSize: number): number {
  return anchor * (frameSize - contentSize)
}

export function layoutFitForImage(
  fit: DocsImageFit
): DocsImageLayoutFit {
  return fit === "cover" ? "cover" : "contain"
}

export function usesCropImageFit(fit: DocsImageFit): boolean {
  return fit === "crop"
}

export function computeImageFitStyle(
  attrs: Pick<DocsGraphicAttrs, "imageFit" | "imageFitAnchorH" | "imageFitAnchorV">
): { objectFit: DocsImageLayoutFit; objectPosition: string } {
  const layoutFit = layoutFitForImage(attrs.imageFit)
  return {
    objectFit: layoutFit,
    objectPosition: `${attrs.imageFitAnchorH * 100}% ${attrs.imageFitAnchorV * 100}%`,
  }
}

export function hasActiveCrop(
  attrs: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
): boolean {
  return (
    attrs.cropX > 0 ||
    attrs.cropY > 0 ||
    attrs.cropWidth < 1 ||
    attrs.cropHeight < 1
  )
}

/** Pixel rect where object-fit contain/cover image draws inside its frame. */
export function computeImageContentRect(
  frameWidth: number,
  frameHeight: number,
  naturalWidth: number,
  naturalHeight: number,
  fit: DocsImageLayoutFit = "contain",
  anchorH: DocsImageFitAnchor = 0.5,
  anchorV: DocsImageFitAnchor = 0.5
): { left: number; top: number; width: number; height: number } {
  if (frameWidth <= 0 || frameHeight <= 0 || naturalWidth <= 0 || naturalHeight <= 0) {
    return { left: 0, top: 0, width: Math.max(frameWidth, 1), height: Math.max(frameHeight, 1) }
  }
  const frameAspect = frameWidth / frameHeight
  const imageAspect = naturalWidth / naturalHeight
  if (fit === "cover") {
    if (imageAspect > frameAspect) {
      const width = frameHeight * imageAspect
      const height = frameHeight
      return {
        left: anchorOffset(anchorH, frameWidth, width),
        top: 0,
        width,
        height,
      }
    }
    const width = frameWidth
    const height = frameWidth / imageAspect
    return {
      left: 0,
      top: anchorOffset(anchorV, frameHeight, height),
      width,
      height,
    }
  }
  if (imageAspect > frameAspect) {
    const width = frameWidth
    const height = frameWidth / imageAspect
    return {
      left: 0,
      top: anchorOffset(anchorV, frameHeight, height),
      width,
      height,
    }
  }
  const width = frameHeight * imageAspect
  const height = frameHeight
  return {
    left: anchorOffset(anchorH, frameWidth, width),
    top: 0,
    width,
    height: frameHeight,
  }
}

const MIN_CROP_SIZE = 0.05

/** Resize normalized crop region from pointer delta; uses drag origin, not live attrs. */
export function resizeCropRegion(
  handle: string,
  origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  dxNorm: number,
  dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
  let cropX = origin.cropX
  let cropY = origin.cropY
  let cropWidth = origin.cropWidth
  let cropHeight = origin.cropHeight

  if (handle.includes("w")) {
    const dx = Math.max(-origin.cropX, Math.min(origin.cropWidth - MIN_CROP_SIZE, dxNorm))
    cropX = origin.cropX + dx
    cropWidth = origin.cropWidth - dx
  } else if (handle.includes("e")) {
    cropWidth = Math.max(MIN_CROP_SIZE, Math.min(1 - origin.cropX, origin.cropWidth + dxNorm))
  }

  if (handle.includes("n")) {
    const dy = Math.max(-origin.cropY, Math.min(origin.cropHeight - MIN_CROP_SIZE, dyNorm))
    cropY = origin.cropY + dy
    cropHeight = origin.cropHeight - dy
  } else if (handle.includes("s")) {
    cropHeight = Math.max(MIN_CROP_SIZE, Math.min(1 - origin.cropY, origin.cropHeight + dyNorm))
  }

  return {
    cropX: roundCrop(cropX),
    cropY: roundCrop(cropY),
    cropWidth: roundCrop(cropWidth),
    cropHeight: roundCrop(cropHeight),
  }
}

function roundCrop(value: number): number {
  return Math.round(value * 10000) / 10000
}

export type CropDisplayGeometry = {
  /** Full source image draw rect (may extend past the frame in cover mode). */
  imageRect: { left: number; top: number; width: number; height: number }
  /** Crop window matching the image as currently shown in the document. */
  windowRect: { left: number; top: number; width: number; height: number }
  cropRect: { left: number; top: number; width: number; height: number }
}

/** Crop window = displayed image bounds; crop handles shrink from that origin. */
export function computeCropEditWindowRect(
  attrs: Pick<DocsGraphicAttrs, "imageFit" | "imageFitAnchorH" | "imageFitAnchorV">,
  frameWidth: number,
  frameHeight: number,
  naturalWidth: number,
  naturalHeight: number
): { left: number; top: number; width: number; height: number } {
  if (attrs.imageFit === "crop") {
    return { left: 0, top: 0, width: frameWidth, height: frameHeight }
  }
  const layoutFit = layoutFitForImage(attrs.imageFit)
  const imageRect = computeImageContentRect(
    frameWidth,
    frameHeight,
    naturalWidth,
    naturalHeight,
    layoutFit,
    attrs.imageFitAnchorH,
    attrs.imageFitAnchorV
  )
  if (layoutFit === "cover") {
    return { left: 0, top: 0, width: frameWidth, height: frameHeight }
  }
  return imageRect
}

/** Full source image rect when a baked crop is shown in the frame. */
export function computeBakedCropImageRect(
  sourceCrop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  frameWidth: number,
  _frameHeight: number,
  naturalWidth: number,
  naturalHeight: number
): { left: number; top: number; width: number; height: number } {
  const cropWpx = Math.max(sourceCrop.cropWidth, 0.01) * naturalWidth
  const scale = frameWidth / cropWpx
  return {
    left: -(sourceCrop.cropX * naturalWidth * scale),
    top: -(sourceCrop.cropY * naturalHeight * scale),
    width: naturalWidth * scale,
    height: naturalHeight * scale,
  }
}

/** Image draw area + crop window in frame pixel coordinates. */
export function computeCropDisplayGeometry(
  attrs: Pick<
    DocsGraphicAttrs,
    | "cropX"
    | "cropY"
    | "cropWidth"
    | "cropHeight"
    | "imageFit"
    | "imageFitAnchorH"
    | "imageFitAnchorV"
  >,
  frameWidth: number,
  frameHeight: number,
  naturalWidth: number,
  naturalHeight: number,
  baseSource?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
): CropDisplayGeometry {
  if (baseSource && hasActiveCrop(baseSource)) {
    const imageRect = computeBakedCropImageRect(
      baseSource,
      frameWidth,
      frameHeight,
      naturalWidth,
      naturalHeight
    )
    const windowRect = imageRect
    return {
      imageRect,
      windowRect,
      cropRect: {
        left: windowRect.left + attrs.cropX * windowRect.width,
        top: windowRect.top + attrs.cropY * windowRect.height,
        width: Math.max(1, attrs.cropWidth * windowRect.width),
        height: Math.max(1, attrs.cropHeight * windowRect.height),
      },
    }
  }

  const editFit: DocsImageFit = attrs.imageFit === "crop" ? "contain" : attrs.imageFit
  const layoutFit = layoutFitForImage(editFit)
  const imageRect = computeImageContentRect(
    frameWidth,
    frameHeight,
    naturalWidth,
    naturalHeight,
    layoutFit,
    attrs.imageFitAnchorH,
    attrs.imageFitAnchorV
  )
  const windowRect = computeCropEditWindowRect(
    { ...attrs, imageFit: editFit },
    frameWidth,
    frameHeight,
    naturalWidth,
    naturalHeight
  )
  return {
    imageRect,
    windowRect,
    cropRect: {
      left: windowRect.left + attrs.cropX * windowRect.width,
      top: windowRect.top + attrs.cropY * windowRect.height,
      width: Math.max(1, attrs.cropWidth * windowRect.width),
      height: Math.max(1, attrs.cropHeight * windowRect.height),
    },
  }
}

/** Map window-relative crop (edit session) to source-normalized 0–1 coords. */
export function windowCropInGeometryToSource(
  windowCrop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  imageRect: { left: number; top: number; width: number; height: number },
  windowRect: { left: number; top: number; width: number; height: number }
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
  const cropFrameLeft = windowRect.left + windowCrop.cropX * windowRect.width
  const cropFrameTop = windowRect.top + windowCrop.cropY * windowRect.height
  const cropFrameW = Math.max(windowCrop.cropWidth * windowRect.width, 1)
  const cropFrameH = Math.max(windowCrop.cropHeight * windowRect.height, 1)

  const cropX = (cropFrameLeft - imageRect.left) / Math.max(imageRect.width, 1)
  const cropY = (cropFrameTop - imageRect.top) / Math.max(imageRect.height, 1)
  const cropWidth = cropFrameW / Math.max(imageRect.width, 1)
  const cropHeight = cropFrameH / Math.max(imageRect.height, 1)

  const clampedX = Math.min(Math.max(cropX, 0), 1)
  const clampedY = Math.min(Math.max(cropY, 0), 1)
  return {
    cropX: roundCrop(clampedX),
    cropY: roundCrop(clampedY),
    cropWidth: roundCrop(Math.min(Math.max(cropWidth, MIN_CROP_SIZE), 1 - clampedX)),
    cropHeight: roundCrop(Math.min(Math.max(cropHeight, MIN_CROP_SIZE), 1 - clampedY)),
  }
}

/** Initial window-relative crop when re-opening edit on a baked crop. */
export function computeCropReeditInitialRegion(
  baseSource: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  frameWidth: number,
  frameHeight: number,
  naturalWidth: number,
  naturalHeight: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
  const imageRect = computeBakedCropImageRect(
    baseSource,
    frameWidth,
    frameHeight,
    naturalWidth,
    naturalHeight
  )
  const cropX = Math.min(Math.max(-imageRect.left / Math.max(imageRect.width, 1), 0), 1)
  const cropY = Math.min(Math.max(-imageRect.top / Math.max(imageRect.height, 1), 0), 1)
  const cropWidth = Math.min(
    Math.max(frameWidth / Math.max(imageRect.width, 1), MIN_CROP_SIZE),
    1 - cropX
  )
  const cropHeight = Math.min(
    Math.max(frameHeight / Math.max(imageRect.height, 1), MIN_CROP_SIZE),
    1 - cropY
  )
  return {
    cropX: roundCrop(cropX),
    cropY: roundCrop(cropY),
    cropWidth: roundCrop(cropWidth),
    cropHeight: roundCrop(cropHeight),
  }
}

/** Narrow an existing source crop with a window-relative edit crop. */
export function compositeSourceCrop(
  base: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  window: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
  return {
    cropX: roundCrop(base.cropX + window.cropX * base.cropWidth),
    cropY: roundCrop(base.cropY + window.cropY * base.cropHeight),
    cropWidth: roundCrop(base.cropWidth * window.cropWidth),
    cropHeight: roundCrop(base.cropHeight * window.cropHeight),
  }
}

/** Merge session pan into persisted crop origin on apply. */
export function mergeCropPanIntoAttrs(
  attrs: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  panX: number,
  panY: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY"> {
  return {
    cropX: roundCrop(
      Math.min(Math.max(attrs.cropX + panX, 0), Math.max(0, 1 - attrs.cropWidth))
    ),
    cropY: roundCrop(
      Math.min(Math.max(attrs.cropY + panY, 0), Math.max(0, 1 - attrs.cropHeight))
    ),
  }
}

/** Frame size + attrs after applying a crop edit (window becomes output size). */
export function computeCropApplyPatch(
  attrs: Pick<
    DocsGraphicAttrs,
    | "cropX"
    | "cropY"
    | "cropWidth"
    | "cropHeight"
    | "imageFit"
    | "imageFitAnchorH"
    | "imageFitAnchorV"
    | "width"
    | "height"
  >,
  panX: number,
  panY: number,
  naturalWidth: number,
  naturalHeight: number,
  baseSource?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight" | "width" | "height" | "imageFit"> {
  const merged = { ...attrs, ...mergeCropPanIntoAttrs(attrs, panX, panY) }
  const geometry = computeCropDisplayGeometry(
    merged,
    attrs.width,
    attrs.height,
    naturalWidth,
    naturalHeight,
    baseSource
  )
  const sourceCrop = windowCropInGeometryToSource(
    merged,
    geometry.imageRect,
    geometry.windowRect
  )
  return {
    cropX: sourceCrop.cropX,
    cropY: sourceCrop.cropY,
    cropWidth: sourceCrop.cropWidth,
    cropHeight: sourceCrop.cropHeight,
    width: Math.max(24, Math.round(geometry.cropRect.width)),
    height: Math.max(24, Math.round(geometry.cropRect.height)),
    imageFit: "crop",
  }
}

/** Crop region maps 1:1 to frame (frame aspect matches crop after apply). */
export function computeCropImageStyle(
  attrs: Pick<
    DocsGraphicAttrs,
    "cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape"
  >,
  frameWidth: number,
  frameHeight: number,
  naturalWidth: number,
  naturalHeight: number
): { img: Record<string, string | number>; clipPath?: string } {
  if (!hasActiveCrop(attrs)) return { img: {} }
  if (frameWidth <= 0 || frameHeight <= 0 || naturalWidth <= 0 || naturalHeight <= 0) {
    return { img: {} }
  }

  const imageRect = computeBakedCropImageRect(
    attrs,
    frameWidth,
    frameHeight,
    naturalWidth,
    naturalHeight
  )

  const img: Record<string, string | number> = {
    position: "absolute",
    left: imageRect.left,
    top: imageRect.top,
    width: imageRect.width,
    height: imageRect.height,
    maxWidth: "none",
  }

  const clipPath =
    attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined

  return { img, clipPath }
}

/** Pan the source image under a fixed crop window (drag follows pointer). */
export function panCropRegion(
  origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
  dxNorm: number,
  dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY"> {
  return {
    cropX: roundCrop(
      Math.min(Math.max(origin.cropX + dxNorm, 0), Math.max(0, 1 - origin.cropWidth))
    ),
    cropY: roundCrop(
      Math.min(Math.max(origin.cropY + dyNorm, 0), Math.max(0, 1 - origin.cropHeight))
    ),
  }
}

export function imageAttrsToGraphic(raw: Record<string, unknown>): Partial<DocsGraphicAttrs> {
  const width =
    typeof raw.width === "number"
      ? raw.width
      : typeof raw.width === "string"
        ? Number(raw.width) || DOCS_GRAPHIC_DEFAULTS.width
        : DOCS_GRAPHIC_DEFAULTS.width
  const height =
    typeof raw.height === "number"
      ? raw.height
      : typeof raw.height === "string"
        ? Number(raw.height) || DOCS_GRAPHIC_DEFAULTS.height
        : DOCS_GRAPHIC_DEFAULTS.height

  return parseGraphicAttrs({
    graphicType: "image",
    src: raw.src,
    alt: raw.alt ?? "",
    width,
    height,
    placement: raw.placement ?? "inline",
    wrap: raw.wrap ?? "inline",
    floatSide: raw.floatSide ?? "left",
    x: raw.x ?? 0,
    y: raw.y ?? 0,
    rotationDeg: raw.rotationDeg ?? 0,
    zIndex: raw.zIndex ?? 0,
    cropX: raw.cropX ?? 0,
    cropY: raw.cropY ?? 0,
    cropWidth: raw.cropWidth ?? 1,
    cropHeight: raw.cropHeight ?? 1,
    cropShape: raw.cropShape ?? "rect",
    assetId: raw.assetId ?? null,
    opacity: raw.opacity ?? 1,
    shadow: raw.shadow ?? "",
  })
}
