// Pure crop math for image elements.
//
// Why this exists
// ───────────────
// Until May 22 2026 this math lived inline in App.jsx's
// `handleApplyCrop` callback. The function intermingled three concerns:
//
//   1. Resolving the natural image dimensions via a Konva.stages walk
//      (impure, depends on the DOM).
//   2. Reading the live `cropping` state and exiting crop mode
//      (React state, impure).
//   3. The actual math: canvas-coords cropRect + current element +
//      naturals → new crop record + tracking fields (pure).
//
// (1) and (2) can't move out of App — they depend on React state and
// the live Konva stage. But (3) is a closed-form function: same
// inputs always produce the same outputs, no side effects. Moving it
// here makes it testable without mounting React or a real canvas,
// which matters because the math is responsible for two bug-prone
// behaviors:
//
//   • flipX/flipY mirror correction. The crop rect comes from a
//     canvas-coord overlay that doesn't know about the element's
//     flip state, so the local-coord conversion has to undo the
//     mirror. Pre-extraction, getting this wrong meant the user
//     would visually crop to the cat's face and end up with a crop
//     pointing at the cat's tail.
//
//   • Multi-crop composition. Cropping an already-cropped image
//     means the new crop's source-image coordinates have to be
//     expressed relative to the FULL natural source, not the
//     current crop window. The scaleX/Y calculation handles this
//     by reading the current element.crop's sWidth/sHeight rather
//     than naturalW/H when present.
//
// What this function returns
// ──────────────────────────
// The full `attrs` patch that the caller should pass to
// `updateAndCommit`. Includes:
//
//   • Geometry (x, y, width, height) — the element takes the crop
//     rect's position and size.
//   • crop — the new source-image sub-region.
//   • preCrop, postCrop, previousCrop — the tracking fields that
//     power the "Undo crop" button's single-step rollback. See the
//     App-side docblock on those fields in handleApplyCrop for the
//     full mental model; the short version is "remember enough to
//     go back one step, even after intervening resizes."
//
// What it does NOT do
// ───────────────────
//   • No rounding. Konva.Image accepts sub-pixel source coords;
//     rounding here would accumulate drift across multi-crop
//     sequences. The caller can round the element bounds if needed
//     for cleaner storage.
//   • No clamping to natural dims. We trust the cropRect to be
//     within the element's bounds — the crop overlay's boundBoxFunc
//     enforces that at drag time. A cropRect that pokes outside
//     would produce a sub-region partially outside the source
//     image; Konva's draw handles that gracefully by clipping.
//   • No validation of element shape (image vs sticker vs text).
//     Caller is responsible for filtering by type. The math itself
//     is type-agnostic — it just needs x/y/width/height/optional
//     crop/optional flip flags.

/**
 * Compute the update patch for applying a crop to an image-like element.
 *
 * @param {Object} params
 * @param {Object} params.element  Element being cropped. Required fields:
 *                                 `x`, `y`, `width`, `height`. Optional:
 *                                 `flipX`, `flipY`, `crop` (with
 *                                 `sx`/`sy`/`sWidth`/`sHeight`).
 * @param {Object} params.cropRect Crop rectangle in canvas/design coords.
 *                                 `{x, y, width, height}`.
 * @param {number} params.naturalW Source image's natural width (px).
 * @param {number} params.naturalH Source image's natural height (px).
 *
 * @returns {Object|null}  Patch for `updateAndCommit`, or `null` if any
 *                          required input is missing or zero-dimensioned.
 *                          The null path matches what handleApplyCrop
 *                          should do when its natural-dims resolution
 *                          fails — bail without committing.
 */
export function computeCropUpdate({ element, cropRect, naturalW, naturalH }) {
  // Guard inputs. Returning null tells the caller "couldn't compute,
  // don't commit a partial patch." Equivalent to handleApplyCrop's
  // bail paths for "image still loading" and "element vanished."
  if (!element || !cropRect) return null;
  if (!naturalW || !naturalH) return null;
  if (!element.width || !element.height) return null;
  if (!cropRect.width || !cropRect.height) return null;

  // Read the current source-image sub-region. If the element has no
  // crop yet, the "current" sub-region is the whole natural image —
  // sx/sy start at 0, sWidth/sHeight at naturalW/H.
  const currentSrcX = element.crop?.sx ?? 0;
  const currentSrcY = element.crop?.sy ?? 0;
  const currentSrcW = element.crop?.sWidth ?? naturalW;
  const currentSrcH = element.crop?.sHeight ?? naturalH;

  // Per-axis scale from canvas (element-local) coords to source-image
  // coords. For an uncropped element this equals naturalW/element.width
  // and naturalH/element.height — the element is just a scaled
  // rendering of the full source. For an already-cropped element, the
  // scale is currentSrcW/element.width — the element renders the
  // current sub-region at the element's displayed size.
  const scaleX = currentSrcW / element.width;
  const scaleY = currentSrcH / element.height;

  // Convert cropRect from canvas coords to element-local coords.
  // localX/localY are the upper-left of the crop within the element's
  // (0..element.width, 0..element.height) box.
  const localX = cropRect.x - element.x;
  const localY = cropRect.y - element.y;

  // Flip correction. The crop overlay renders in canvas coords, so a
  // user clicking on the visually-displayed "left ear" of a horizontally
  // flipped image is selecting the corresponding region in CANVAS space.
  // But the source image isn't flipped — Konva applies the flip at
  // render time. To select the right region of the SOURCE we have to
  // mirror back across the local axis.
  //
  // The mirror formula for X: with the element's local box being
  // [0..element.width], the canvas-space localX corresponds to
  // source-space (element.width - localX - cropRect.width) — the
  // crop's RIGHT edge in canvas space is its LEFT edge in source
  // space (and vice versa for the width). Same logic for Y when
  // flipY is set.
  const localCropX = element.flipX
    ? element.width - localX - cropRect.width
    : localX;
  const localCropY = element.flipY
    ? element.height - localY - cropRect.height
    : localY;

  // Compose with currentSrcX/Y so multi-crop sequences land in the
  // right source-coord region. Without the addition, a second crop
  // would treat its localCropX as an offset from sx=0 instead of
  // from the previous crop's sx — the new crop would jump back to
  // the upper-left of the SOURCE rather than narrowing into the
  // previous crop's window.
  const newCrop = {
    sx: currentSrcX + localCropX * scaleX,
    sy: currentSrcY + localCropY * scaleY,
    sWidth: cropRect.width * scaleX,
    sHeight: cropRect.height * scaleY,
  };

  return {
    // The element's display geometry becomes the crop rect — same
    // x/y/width/height as the user dragged. The cropped sub-region
    // (crop) renders inside this box.
    x: cropRect.x,
    y: cropRect.y,
    width: cropRect.width,
    height: cropRect.height,

    crop: newCrop,

    // Tracking fields powering the "Undo crop" button's single-step
    // rollback. See App-side handleApplyCrop / handleUndoCrop for
    // the full rationale; the short version:
    //
    //   preCrop: the dims immediately BEFORE this crop. Used by
    //     "Undo crop" to restore bounds when the user hasn't
    //     resized since this crop apply.
    //
    //   postCrop: the dims immediately AFTER this crop. Compared
    //     against current bounds at undo time to decide whether the
    //     user resized between the crop and the undo — if they did,
    //     undo falls through to the fit-to-bounds branch instead of
    //     literal restore.
    //
    //   previousCrop: the crop value that was active immediately
    //     before this apply (null for a first-time crop). Restored
    //     on undo so multi-crop sequences go back ONE level rather
    //     than jumping to fully-uncropped.
    preCrop: { width: element.width, height: element.height },
    postCrop: { width: cropRect.width, height: cropRect.height },
    previousCrop: element.crop ?? null,
  };
}
