// A core 2D drawing surface that bakes to a texture — see docs/canvas-contract.md. It is NOT part of
// creator-2d/creator-gl: the texture it produces is consumed by 2D sprites, 3D materials and UI
// images alike ("a canvas is just a texture"), so world/label text and arbitrary 2D graphics are
// drawn once, per platform, on that platform's native 2D stack (web OffscreenCanvas, iOS CoreGraphics,
// Android Canvas).
//
// The API looks immediate but RECORDS commands into a compact buffer and flushes them to the backend
// ONCE per bake (one bridge crossing, not one per lineTo). Colors and fonts are CSS strings, resolved
// by the platform. Do all drawing AFTER `await registerFont(...)` — an unloaded font mis-measures.
//
// State (fillStyle, font, textAlign, …) is get/set like the browser; drawing calls are methods (they
// return `this`, so drawing still chains). `pixelRatio` is the device-pixel multiplier (browser-style
// `scale(sx, sy)` is a transform).
//
//   const c = new Canvas(160, 40, { pixelRatio: 2 })
//   c.font = 'bold 24px Inter'
//   const w = c.measureText('Alice').width + 24
//   c.resize(w, 40)
//   c.fillStyle = '#000a'; c.roundRect(0, 0, w, 40, 12).fill()
//   c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText('Alice', w / 2, 27)
//   new Sprite({ texture: c })          // Canvas is a valid texture source everywhere

import { Texture2D } from "../g2/Texture2D"
import { File, type FetchResponse } from "../runtime/fetch"
import type { SvgSourceValue } from "../runtime/misc"

// Opcodes — MUST stay in sync with docs/canvas-contract.md.
const OP = {
  SAVE: 0, RESTORE: 1, TRANSLATE: 2, SCALE: 3, ROTATE: 4, GLOBAL_ALPHA: 5,
  FILL_STYLE: 6, STROKE_STYLE: 7, LINE_WIDTH: 8, LINE_JOIN: 9, LINE_CAP: 10,
  FONT: 11, TEXT_ALIGN: 12, TEXT_BASELINE: 13,
  BEGIN_PATH: 14, CLOSE_PATH: 15, MOVE_TO: 16, LINE_TO: 17, QUADRATIC_TO: 18, BEZIER_TO: 19,
  ARC: 20, RECT: 21, ROUND_RECT: 22, FILL: 23, STROKE: 24,
  FILL_RECT: 25, STROKE_RECT: 26, CLEAR_RECT: 27, FILL_TEXT: 28, STROKE_TEXT: 29,
  DRAW_IMAGE: 30,
} as const

const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
const JOIN = { miter: 0, round: 1, bevel: 2 } as const
const CAP = { butt: 0, round: 1, square: 2 } as const

export type TextAlign = keyof typeof ALIGN
export type TextBaseline = keyof typeof BASELINE
export type LineJoin = keyof typeof JOIN
export type LineCap = keyof typeof CAP
export interface CanvasTextMetrics { width: number, ascent: number, descent: number }

export class Canvas {
  /** Brand so `Sprite`/`UIImage`/`Material` can accept a Canvas anywhere a texture source is expected. */
  readonly __canvas = true as const

  width: number
  height: number
  /** Device-pixel multiplier (draw at 2 for crisp retina text). Applied by the backend. */
  pixelRatio: number

  private _cmd: number[] = []
  private _refs: string[] = []
  // Shadowed drawing state so the get/set accessors read back the current value (like the browser).
  private _globalAlpha = 1
  private _fillStyle = "#000000"
  private _strokeStyle = "#000000"
  private _lineWidth = 1
  private _lineJoin: LineJoin = "miter"
  private _lineCap: LineCap = "butt"
  private _font = "10px sans-serif"
  private _textAlign: TextAlign = "start"
  private _textBaseline: TextBaseline = "alphabetic"
  private _surfaceId = 0
  private _tex2d?: Texture2D   // cached 2D texture, re-uploaded on update()
  /** @internal — cached 3D (creator-gl) texture id, set by gl/Texture.fromCanvas; re-uploaded on
   *  update(). Kept as a bare id (not a Texture import) so a 2D-only canvas never pulls the 3D engine. */
  _tex3dId = 0

  constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
    this.width = width
    this.height = height
    this.pixelRatio = opts.pixelRatio ?? 1
  }

  private _ref(s: string): number {
    const i = this._refs.indexOf(s)
    if (i >= 0) return i
    this._refs.push(s)
    return this._refs.length - 1
  }

  /** Discard the recorded drawing to start a new one (for dynamic canvases). */
  reset(): this { this._cmd.length = 0; this._refs.length = 0; return this }
  /** Change the surface size; takes effect on the next bake. */
  resize(width: number, height: number): this { this.width = width; this.height = height; return this }

  // --- transforms & state stack (actions; chainable) ---
  save(): this { this._cmd.push(OP.SAVE); return this }
  restore(): this { this._cmd.push(OP.RESTORE); return this }
  translate(x: number, y: number): this { this._cmd.push(OP.TRANSLATE, x, y); return this }
  scale(sx: number, sy: number): this { this._cmd.push(OP.SCALE, sx, sy); return this }
  rotate(rad: number): this { this._cmd.push(OP.ROTATE, rad); return this }

  // --- drawing state (get/set like the browser; colors/fonts are CSS strings resolved by the platform).
  // The setter records the state op AND shadows the value so the getter reads it back.
  get globalAlpha(): number { return this._globalAlpha }
  set globalAlpha(a: number) { this._globalAlpha = a; this._cmd.push(OP.GLOBAL_ALPHA, a) }
  get fillStyle(): string { return this._fillStyle }
  set fillStyle(color: string) { this._fillStyle = color; this._cmd.push(OP.FILL_STYLE, this._ref(color)) }
  get strokeStyle(): string { return this._strokeStyle }
  set strokeStyle(color: string) { this._strokeStyle = color; this._cmd.push(OP.STROKE_STYLE, this._ref(color)) }
  get lineWidth(): number { return this._lineWidth }
  set lineWidth(w: number) { this._lineWidth = w; this._cmd.push(OP.LINE_WIDTH, w) }
  get lineJoin(): LineJoin { return this._lineJoin }
  set lineJoin(j: LineJoin) { this._lineJoin = j; this._cmd.push(OP.LINE_JOIN, JOIN[j]) }
  get lineCap(): LineCap { return this._lineCap }
  set lineCap(c: LineCap) { this._lineCap = c; this._cmd.push(OP.LINE_CAP, CAP[c]) }
  get font(): string { return this._font }
  set font(f: string) { this._font = f; this._cmd.push(OP.FONT, this._ref(f)) }
  get textAlign(): TextAlign { return this._textAlign }
  set textAlign(a: TextAlign) { this._textAlign = a; this._cmd.push(OP.TEXT_ALIGN, ALIGN[a]) }
  get textBaseline(): TextBaseline { return this._textBaseline }
  set textBaseline(b: TextBaseline) { this._textBaseline = b; this._cmd.push(OP.TEXT_BASELINE, BASELINE[b]) }

  // --- paths ---
  beginPath(): this { this._cmd.push(OP.BEGIN_PATH); return this }
  closePath(): this { this._cmd.push(OP.CLOSE_PATH); return this }
  moveTo(x: number, y: number): this { this._cmd.push(OP.MOVE_TO, x, y); return this }
  lineTo(x: number, y: number): this { this._cmd.push(OP.LINE_TO, x, y); return this }
  quadraticCurveTo(cx: number, cy: number, x: number, y: number): this { this._cmd.push(OP.QUADRATIC_TO, cx, cy, x, y); return this }
  bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
    this._cmd.push(OP.BEZIER_TO, c1x, c1y, c2x, c2y, x, y); return this
  }
  arc(x: number, y: number, r: number, a0: number, a1: number, ccw = false): this {
    this._cmd.push(OP.ARC, x, y, r, a0, a1, ccw ? 1 : 0); return this
  }
  rect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.RECT, x, y, w, h); return this }
  roundRect(x: number, y: number, w: number, h: number, r: number): this { this._cmd.push(OP.ROUND_RECT, x, y, w, h, r); return this }
  fill(evenOdd = false): this { this._cmd.push(OP.FILL, evenOdd ? 1 : 0); return this }
  stroke(): this { this._cmd.push(OP.STROKE); return this }

  // --- shape/text sugar ---
  fillRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.FILL_RECT, x, y, w, h); return this }
  strokeRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.STROKE_RECT, x, y, w, h); return this }
  clearRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.CLEAR_RECT, x, y, w, h); return this }
  fillText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.FILL_TEXT, this._ref(text), x, y, maxWidth); return this }
  strokeText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.STROKE_TEXT, this._ref(text), x, y, maxWidth); return this }

  // --- images ---
  /** Blit a `Bitmap` (a snapshot from `toBitmap()`) onto this canvas. All coordinates are LOGICAL —
   *  including the source sub-rect in the 9-arg form (it's scaled by the bitmap's pixelRatio here).
   *  Records a DRAW_IMAGE op, so the blit runs at bake time: keep the Bitmap alive until the next
   *  `update()`/`toBitmap()`/`texture()`. Three browser-style forms:
   *    drawImage(bmp, dx, dy)                         — whole bitmap at its natural size
   *    drawImage(bmp, dx, dy, dw, dh)                 — whole bitmap, scaled into dw×dh
   *    drawImage(bmp, sx, sy, sw, sh, dx, dy, dw, dh) — a sub-rect of the bitmap, scaled into dw×dh */
  drawImage(img: Bitmap, dx: number, dy: number): this
  drawImage(img: Bitmap, dx: number, dy: number, dw: number, dh: number): this
  drawImage(img: Bitmap, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): this
  drawImage(img: Bitmap, a: number, b: number, c?: number, d?: number, e?: number, f?: number, g?: number, h?: number): this {
    const pr = img.pixelRatio
    let sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number
    if (e === undefined) {                    // (dx, dy) or (dx, dy, dw, dh): the whole bitmap
      sx = 0; sy = 0; sw = img.pixelWidth; sh = img.pixelHeight
      dx = a; dy = b; dw = c ?? img.width; dh = d ?? img.height
    } else {                                  // (sx, sy, sw, sh, dx, dy, dw, dh): a sub-rect (logical → device px)
      sx = a * pr; sy = b * pr; sw = c! * pr; sh = d! * pr
      dx = e; dy = f!; dw = g!; dh = h!
    }
    this._cmd.push(OP.DRAW_IMAGE, img._surfaceId, sx, sy, sw, sh, dx, dy, dw, dh)
    return this
  }

  /** Decode an image source into a standalone `Bitmap` you can blit with `drawImage()` — either an
   *  `SvgSource(...)` (rasterized) or a `FetchResponse` holding already-fetched image bytes (decoded).
   *  This is async because turning SVG markup / encoded bytes into pixels needs a decode step (on the
   *  web, `createImageBitmap` / an `<img>` load); `await` it ONCE, then `drawImage(bitmap, …)` is
   *  synchronous and the Bitmap is reusable. The returned Bitmap owns a native surface at the image's
   *  natural pixel size (`pixelRatio` 1) — `destroy()` it when done. Typical use:
   *    const logo = await canvas.loadImage(SvgSource(svg))   // or: await fetch(url) → the response
   *    canvas.drawImage(logo, 0, 0)                          // sync, blit as often as you like */
  loadImage(source: SvgSourceValue | FetchResponse): Promise<Bitmap> {
    // Discriminate without touching FetchResponse internals: only an SvgSource carries `.svg`.
    const isSvg = typeof (source as SvgSourceValue).svg === "string"
    const svg = isSvg ? (source as SvgSourceValue).svg : ""
    const bufferId = isSvg ? -1 : (source as any)._id   // <0 = the SVG path; else the host buffer id
    return new Promise<Bitmap>((res, rej) => {
      _creatorCanvas.loadImage(svg, bufferId, (surfaceId, w, h) => res(new Bitmap(surfaceId, w, h, 1)), rej)
    })
  }

  /** Measure `text` in the current font on THIS platform. The one synchronous round-trip. */
  measureText(text: string): CanvasTextMetrics {
    const o = _creatorCanvas.measureText(text, this._font)
    return { width: o[0], ascent: o[1], descent: o[2] }
  }

  /** @internal — rasterize the recorded commands into the surface; returns the surface id. */
  _bakeSurface(): number {
    const buf = new Float32Array(this._cmd)
    this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
    return this._surfaceId
  }

  /** Bake and return the 2D texture (created once, then cached). */
  texture(): Texture2D {
    if (!this._tex2d) this._tex2d = Texture2D.fromCanvas(this)
    return this._tex2d
  }

  /** Re-rasterize and re-upload to every texture this canvas produced (call after redrawing) — the
   *  cached 2D and/or 3D texture, whichever engines this canvas was baked into. */
  update(): this {
    const surfaceId = this._bakeSurface()
    if (this._tex2d) _creator2d.updateTextureFromCanvas(this._tex2d.id, surfaceId)
    if (this._tex3dId) _creator.updateTextureFromCanvas(this._tex3dId, surfaceId)
    return this
  }

  /** Copy the current pixels into a standalone, immutable `Bitmap` — independent of this canvas, so
   *  later drawing/`reset()` never touches it. Draw it back with `drawImage()`, export it with
   *  `toDataURL()`, or use it to "flatten" an ever-growing drawing so re-bakes stay O(1):
   *    const snap = canvas.toBitmap()
   *    canvas.reset(); canvas.drawImage(snap, 0, 0)   // one op replaces the whole history
   *    // …keep drawing on top; snap.destroy() once it's been drawn in. */
  toBitmap(): Bitmap {
    return new Bitmap(_creatorCanvas.snapshot(this._bakeSurface()), this.width, this.height, this.pixelRatio)
  }

  /** Encode the current pixels to an image `File` — 'image/png' (default) or 'image/jpeg'. The File
   *  is a host buffer handle {id,name,size}: append it to a FormData to upload, load it as a texture
   *  (`Texture2D.load(file)`), or hand it to a share/save sheet. */
  toFile(name = "canvas.png", type = "image/png"): Promise<File> {
    const surfaceId = this._bakeSurface()
    return new Promise<File>((res, rej) => {
      _creatorCanvas.toFile(surfaceId, type, (bufferId, size) => res(new File(bufferId, name, size)), rej)
    })
  }

  destroy(): void {
    if (this._surfaceId) _creatorCanvas.destroySurface(this._surfaceId)
    this._tex2d?.destroy()
    // The 3D texture (if any) is owned by creator-gl's engine and freed at its teardown — no per-texture
    // destroy in the 3D API — so we just drop our cached id here.
    this._surfaceId = 0
    this._tex2d = undefined
    this._tex3dId = 0
  }
}

/** Duck-type guard so consumers can accept a Canvas anywhere a texture source is expected. */
export const isCanvas = (v: unknown): v is Canvas =>
  typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true

/** An immutable RGBA8 snapshot of a canvas, produced by `Canvas.toBitmap()`. It owns its own baked
 *  surface (a copy), so the source canvas can keep drawing or `reset()` without affecting it. Use it
 *  to stamp/blit back (`canvas.drawImage(bitmap, …)`), to export (`bitmap.toDataURL()`), or as a
 *  cheap "layer" you composite. Call `destroy()` when done — it holds a native surface. */
export class Bitmap {
  readonly __bitmap = true as const
  /** @internal — a standalone `_creatorCanvas` surface (RGBA8), independent of any Canvas. */
  readonly _surfaceId: number
  /** Logical width, matching the Canvas it was captured from (pixelRatio is crispness, not size). */
  readonly width: number
  readonly height: number
  readonly pixelRatio: number

  constructor(surfaceId: number, width: number, height: number, pixelRatio: number) {
    this._surfaceId = surfaceId
    this.width = width
    this.height = height
    this.pixelRatio = pixelRatio
  }

  /** Device-pixel dimensions of the stored bitmap (`width * pixelRatio`, rounded). */
  get pixelWidth(): number { return Math.round(this.width * this.pixelRatio) }
  get pixelHeight(): number { return Math.round(this.height * this.pixelRatio) }

  /** Encode to an image `File` — 'image/png' (default) or 'image/jpeg'. See `Canvas.toFile`. */
  toFile(name = "canvas.png", type = "image/png"): Promise<File> {
    return new Promise<File>((res, rej) => {
      _creatorCanvas.toFile(this._surfaceId, type, (bufferId, size) => res(new File(bufferId, name, size)), rej)
    })
  }

  /** Free the native surface. The bitmap must not be drawn or encoded afterwards. */
  destroy(): void { _creatorCanvas.destroySurface(this._surfaceId) }
}

/** Duck-type guard for a Bitmap (see `isCanvas`). */
export const isBitmap = (v: unknown): v is Bitmap =>
  typeof v === "object" && v !== null && (v as { __bitmap?: unknown }).__bitmap === true
