/* global React, Icon, IconBtn, copyText, db, useKvRecord */
const { useState, useEffect, useMemo, useRef, useCallback } = React;

// ============================================================
// Image tools — local, client-side conversions & edits
// Nothing leaves the browser; everything runs on a <canvas>.
// ============================================================

function imgFormatBytes(n) {
  if (n == null) return '—';
  if (n < 1024) return n + ' B';
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
  return (n / 1024 / 1024).toFixed(2) + ' MB';
}

function imgLoad(src) {
  return new Promise((resolve, reject) => {
    const im = new Image();
    im.onload = () => resolve(im);
    im.onerror = () => reject(new Error('Could not load image'));
    im.src = src;
  });
}

function imgFileToDataURL(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = () => reject(new Error('Could not read file'));
    r.readAsDataURL(file);
  });
}

function imgDownload(blobOrUrl, filename) {
  const url = typeof blobOrUrl === 'string' ? blobOrUrl : URL.createObjectURL(blobOrUrl);
  const a = document.createElement('a');
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); a.remove();
  if (typeof blobOrUrl !== 'string') setTimeout(() => URL.revokeObjectURL(url), 1500);
}

function imgBaseName(name) {
  if (!name) return 'image';
  return name.replace(/\.[^.]+$/, '');
}

// ------------------------------------------------------------
// Operation catalog (mirrors the requested tool set)
// ------------------------------------------------------------
const IMG_OPS = [
  // --- Format conversions ---
  { id: 'webp2jpg', label: 'WEBP to JPG', icon: 'image',          desc: 'Convert a WEBP image to JPG.',          kind: 'convert', accept: 'image/webp,.webp',           target: 'image/jpeg', ext: 'jpg', flatten: true },
  { id: 'png2jpg',  label: 'PNG to JPG',  icon: 'image',          desc: 'Convert a PNG image to JPG.',           kind: 'convert', accept: 'image/png,.png',             target: 'image/jpeg', ext: 'jpg', flatten: true },
  { id: 'jpg2png',  label: 'JPG to PNG',  icon: 'image',          desc: 'Convert a JPG/JPEG image to PNG.',       kind: 'convert', accept: 'image/jpeg,.jpg,.jpeg',      target: 'image/png',  ext: 'png' },
  { id: 'gif2png',  label: 'GIF to PNG',  icon: 'gif',            desc: 'Convert a GIF (first frame) to PNG.',    kind: 'convert', accept: 'image/gif,.gif',             target: 'image/png',  ext: 'png' },
  { id: 'avif2jpg', label: 'AVIF to JPG', icon: 'image',          desc: 'Convert an AVIF image to JPG.',          kind: 'convert', accept: 'image/avif,.avif',           target: 'image/jpeg', ext: 'jpg', flatten: true },
  { id: 'jpg2avif', label: 'JPG to AVIF', icon: 'image',          desc: 'Convert a JPG/JPEG image to AVIF.',      kind: 'convert', accept: 'image/jpeg,.jpg,.jpeg',      target: 'image/avif', ext: 'avif' },
  { id: 'png2svg',  label: 'PNG to SVG',  icon: 'image',          desc: 'Wrap a PNG inside an SVG container.',    kind: 'tosvg',   accept: 'image/png,.png' },
  { id: 'jpg2svg',  label: 'JPG to SVG',  icon: 'image',          desc: 'Wrap a JPG inside an SVG container.',    kind: 'tosvg',   accept: 'image/jpeg,.jpg,.jpeg' },
  { id: 'svg2png',  label: 'SVG to PNG',  icon: 'image',          desc: 'Rasterize an SVG to a PNG at any scale.', kind: 'svg2png', accept: 'image/svg+xml,.svg' },
  // --- Edits ---
  { id: 'crop',     label: 'Image cropper', icon: 'crop',         desc: 'Crop JPG, PNG or GIF to a pixel rectangle.', kind: 'crop',  accept: 'image/*' },
  { id: 'resize',   label: 'Resize image', icon: 'aspect_ratio',  desc: 'Resize an image to exact pixel dimensions.', kind: 'resize', accept: 'image/*' },
  { id: 'rotate',   label: 'Rotate & flip', icon: 'rotate_right', desc: 'Rotate 90° or mirror an image.',         kind: 'rotate',  accept: 'image/*' },
  { id: 'compress', label: 'Compress an image', icon: 'compress', desc: 'Reduce the file size of an image.',      kind: 'compress', accept: 'image/*' },
  { id: 'invert',   label: 'Invert colors', icon: 'invert_colors', desc: 'Invert the colors of an image.',        kind: 'filter',  accept: 'image/*', filter: 'invert' },
  { id: 'bw',       label: 'Black and White', icon: 'filter_b_and_w', desc: 'Convert an image to black and white.', kind: 'filter', accept: 'image/*', filter: 'bw' },
  // --- BASE64 ---
  { id: 'img2b64',  label: 'Image to BASE64', icon: 'code',       desc: 'Convert any image to a BASE64 string.',  kind: 'tobase64', accept: 'image/*' },
  { id: 'b642img',  label: 'BASE64 to Image', icon: 'code',       desc: 'Turn a BASE64 string back into an image.', kind: 'frombase64' },
];
const IMG_OP_BY_ID = Object.fromEntries(IMG_OPS.map(o => [o.id, o]));

// ------------------------------------------------------------
// Canvas processors
// ------------------------------------------------------------
function imgToCanvas(img, w, h) {
  const c = document.createElement('canvas');
  c.width = w || img.naturalWidth || img.width;
  c.height = h || img.naturalHeight || img.height;
  return c;
}

function processRaster(img, op, opts = {}) {
  const c = imgToCanvas(img, opts.w, opts.h);
  const g = c.getContext('2d');
  if (op.flatten) { g.fillStyle = '#ffffff'; g.fillRect(0, 0, c.width, c.height); }
  g.drawImage(img, 0, 0, c.width, c.height);
  if (op.filter === 'invert' || op.filter === 'bw') {
    const d = g.getImageData(0, 0, c.width, c.height);
    const a = d.data;
    for (let i = 0; i < a.length; i += 4) {
      if (op.filter === 'invert') {
        a[i] = 255 - a[i]; a[i + 1] = 255 - a[i + 1]; a[i + 2] = 255 - a[i + 2];
      } else {
        const l = 0.299 * a[i] + 0.587 * a[i + 1] + 0.114 * a[i + 2];
        a[i] = a[i + 1] = a[i + 2] = l;
      }
    }
    g.putImageData(d, 0, 0);
  }
  return c;
}

function canvasToBlob(canvas, type, quality) {
  return new Promise(res => canvas.toBlob(b => res(b), type, quality));
}

function buildSVGWrapper(dataUrl, w, h) {
  return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n  <image width="${w}" height="${h}" xlink:href="${dataUrl}"/>\n</svg>\n`;
}

// ============================================================
// Source picker (drag & drop + click)
// ============================================================
function ImgDropzone({ accept, onFile, compact }) {
  const inputRef = useRef(null);
  const [over, setOver] = useState(false);
  const handle = (file) => { if (file) onFile(file); };
  return (
    <div
      className={`imgt-drop ${over ? 'is-over' : ''} ${compact ? 'is-compact' : ''}`}
      onClick={() => inputRef.current?.click()}
      onDragOver={e => { e.preventDefault(); setOver(true); }}
      onDragLeave={() => setOver(false)}
      onDrop={e => { e.preventDefault(); setOver(false); handle(e.dataTransfer.files?.[0]); }}
    >
      <Icon name="upload_file" />
      <div className="imgt-drop-title">Drop an image here, or click to browse</div>
      <div className="imgt-drop-sub">Everything runs locally — your image never leaves this device.</div>
      <input
        ref={inputRef} type="file" accept={accept || 'image/*'} hidden
        onChange={e => { handle(e.target.files?.[0]); e.target.value = ''; }}
      />
    </div>
  );
}

// Small reusable result preview + download row
function ImgResult({ url, filename, sizeLabel, onDownload, extra }) {
  return (
    <div className="imgt-result">
      <div className="imgt-canvas-wrap">
        <img src={url} alt="result" />
      </div>
      <div className="imgt-result-foot">
        {sizeLabel && <span className="imgt-size">{sizeLabel}</span>}
        {extra}
        <div style={{ flex: 1 }} />
        <button className="lh-btn primary" onClick={onDownload}>
          <Icon name="download" />Download
        </button>
      </div>
    </div>
  );
}

// ============================================================
// Generic convert / filter / svg-rasterize / to-svg panel
// ============================================================
function ConvertPanel({ op }) {
  const [src, setSrc] = useState(null); // { file, dataURL, img, w, h }
  const [out, setOut] = useState(null); // { url, blob, filename, size }
  const [err, setErr] = useState(null);
  const [busy, setBusy] = useState(false);
  const [scale, setScale] = useState(2); // for svg2png

  useEffect(() => () => { if (out?.url) URL.revokeObjectURL(out.url); }, [out]);

  const loadFile = useCallback(async (file) => {
    setErr(null); setOut(null);
    try {
      const dataURL = await imgFileToDataURL(file);
      const img = await imgLoad(dataURL);
      setSrc({ file, dataURL, img, w: img.naturalWidth, h: img.naturalHeight });
    } catch (e) { setErr(e.message); setSrc(null); }
  }, []);

  const run = useCallback(async () => {
    if (!src) return;
    setBusy(true); setErr(null);
    try {
      const base = imgBaseName(src.file.name);
      if (op.kind === 'tosvg') {
        const svg = buildSVGWrapper(src.dataURL, src.w, src.h);
        const blob = new Blob([svg], { type: 'image/svg+xml' });
        const url = URL.createObjectURL(blob);
        setOut({ url, blob, filename: `${base}.svg`, size: blob.size });
      } else if (op.kind === 'svg2png') {
        const w = Math.round(src.w * scale), h = Math.round(src.h * scale);
        const canvas = processRaster(src.img, op, { w, h });
        const blob = await canvasToBlob(canvas, 'image/png');
        const url = URL.createObjectURL(blob);
        setOut({ url, blob, filename: `${base}.png`, size: blob.size, dims: `${w}×${h}` });
      } else {
        const canvas = processRaster(src.img, op);
        const blob = await canvasToBlob(canvas, op.target || 'image/png', 0.92);
        if (!blob) throw new Error('Conversion failed — your browser could not encode this format.');
        let filename = `${base}.${op.ext || 'png'}`;
        let warn = null;
        // toBlob silently falls back to PNG when it can't encode the requested
        // type (common for AVIF encoding). Detect it and label honestly.
        if (op.target && blob.type && blob.type !== op.target) {
          const realExt = (blob.type.split('/')[1] || 'png').replace('+xml', '');
          filename = `${base}.${realExt}`;
          warn = `This browser can't encode ${(op.ext || '').toUpperCase()} — saved as ${realExt.toUpperCase()} instead.`;
        }
        const url = URL.createObjectURL(blob);
        setOut({ url, blob, filename, size: blob.size, warn });
      }
    } catch (e) { setErr(e.message); }
    setBusy(false);
  }, [src, op, scale]);

  // Auto-run for instant conversions (not svg2png which has a control)
  useEffect(() => {
    if (src && op.kind !== 'svg2png') run();
  }, [src, op.kind]); // eslint-disable-line

  return (
    <div className="imgt-panel">
      {!src ? (
        <ImgDropzone accept={op.accept} onFile={loadFile} />
      ) : (
        <div className="imgt-work">
          <div className="imgt-source">
            <div className="imgt-source-thumb"><img src={src.dataURL} alt="source" /></div>
            <div className="imgt-source-meta">
              <div className="imgt-source-name">{src.file.name}</div>
              <div className="imgt-source-dims">{src.w}×{src.h} px · {imgFormatBytes(src.file.size)}</div>
            </div>
            <button className="lh-chip" onClick={() => { setSrc(null); setOut(null); }}>
              <Icon name="close" />Change
            </button>
          </div>

          {op.kind === 'svg2png' && (
            <div className="imgt-controls">
              <label className="imgt-field">
                <span>Scale</span>
                <div className="imgt-scale-row">
                  <input type="range" min="0.25" max="8" step="0.25" value={scale}
                    onChange={e => setScale(parseFloat(e.target.value))} />
                  <span className="imgt-scale-val">{scale}×</span>
                </div>
                <span className="imgt-hint">Output: {Math.round(src.w * scale)}×{Math.round(src.h * scale)} px</span>
              </label>
              <button className="lh-btn primary" onClick={run} disabled={busy}>
                <Icon name="bolt" />Rasterize
              </button>
            </div>
          )}

          {err && <div className="imgt-error"><Icon name="error" />{err}</div>}

          {out && (
            <ImgResult
              url={out.url}
              sizeLabel={`${imgFormatBytes(out.size)}${out.dims ? ' · ' + out.dims : ''}`}
              onDownload={() => imgDownload(out.blob, out.filename)}
              extra={op.kind === 'tosvg' && (
                <span className="imgt-note">SVG wraps the original pixels — it is not vector-traced.</span>
              )}
            />
          )}
          {out?.warn && <div className="imgt-warn"><Icon name="warning" />{out.warn}</div>}
          {busy && <div className="imgt-busy"><Icon name="progress_activity" />Working…</div>}
        </div>
      )}
    </div>
  );
}

// ============================================================
// Compress panel — quality slider + before/after
// ============================================================
function CompressPanel({ op }) {
  const [src, setSrc] = useState(null);
  const [quality, setQuality] = useState(0.7);
  const [out, setOut] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  useEffect(() => () => { if (out?.url) URL.revokeObjectURL(out.url); }, [out]);

  const loadFile = useCallback(async (file) => {
    setErr(null); setOut(null);
    try {
      const dataURL = await imgFileToDataURL(file);
      const img = await imgLoad(dataURL);
      setSrc({ file, dataURL, img, w: img.naturalWidth, h: img.naturalHeight });
    } catch (e) { setErr(e.message); }
  }, []);

  const run = useCallback(async () => {
    if (!src) return;
    setBusy(true);
    try {
      const canvas = processRaster(src.img, { flatten: true });
      const blob = await canvasToBlob(canvas, 'image/jpeg', quality);
      const url = URL.createObjectURL(blob);
      setOut({ url, blob, filename: `${imgBaseName(src.file.name)}-compressed.jpg`, size: blob.size });
    } catch (e) { setErr(e.message); }
    setBusy(false);
  }, [src, quality]);

  useEffect(() => { if (src) run(); }, [src, quality]); // eslint-disable-line

  const saved = out && src ? 1 - out.size / src.file.size : 0;

  return (
    <div className="imgt-panel">
      {!src ? (
        <ImgDropzone accept={op.accept} onFile={loadFile} />
      ) : (
        <div className="imgt-work">
          <div className="imgt-source">
            <div className="imgt-source-thumb"><img src={src.dataURL} alt="source" /></div>
            <div className="imgt-source-meta">
              <div className="imgt-source-name">{src.file.name}</div>
              <div className="imgt-source-dims">{src.w}×{src.h} px · {imgFormatBytes(src.file.size)}</div>
            </div>
            <button className="lh-chip" onClick={() => { setSrc(null); setOut(null); }}>
              <Icon name="close" />Change
            </button>
          </div>

          <div className="imgt-controls">
            <label className="imgt-field">
              <span>Quality</span>
              <div className="imgt-scale-row">
                <input type="range" min="0.1" max="1" step="0.05" value={quality}
                  onChange={e => setQuality(parseFloat(e.target.value))} />
                <span className="imgt-scale-val">{Math.round(quality * 100)}%</span>
              </div>
            </label>
          </div>

          {err && <div className="imgt-error"><Icon name="error" />{err}</div>}

          {out && (
            <ImgResult
              url={out.url}
              sizeLabel={`${imgFormatBytes(src.file.size)} → ${imgFormatBytes(out.size)}`}
              onDownload={() => imgDownload(out.blob, out.filename)}
              extra={
                <span className={`imgt-badge ${saved > 0 ? 'is-good' : 'is-bad'}`}>
                  {saved > 0 ? `−${Math.round(saved * 100)}% smaller` : `+${Math.round(-saved * 100)}% larger`}
                </span>
              }
            />
          )}
          {busy && <div className="imgt-busy"><Icon name="progress_activity" />Working…</div>}
        </div>
      )}
    </div>
  );
}

// ============================================================
// Crop panel — draw a rectangle / type pixel coords
// ============================================================
function CropPanel({ op }) {
  const [src, setSrc] = useState(null);
  const [rect, setRect] = useState({ x: 0, y: 0, w: 0, h: 0 });
  const [out, setOut] = useState(null);
  const [err, setErr] = useState(null);
  const stageRef = useRef(null);
  const dragRef = useRef(null);

  useEffect(() => () => { if (out?.url) URL.revokeObjectURL(out.url); }, [out]);

  const loadFile = useCallback(async (file) => {
    setErr(null); setOut(null);
    try {
      const dataURL = await imgFileToDataURL(file);
      const img = await imgLoad(dataURL);
      setSrc({ file, dataURL, img, w: img.naturalWidth, h: img.naturalHeight });
      setRect({ x: 0, y: 0, w: img.naturalWidth, h: img.naturalHeight });
    } catch (e) { setErr(e.message); }
  }, []);

  const scaleOf = () => {
    const el = stageRef.current;
    if (!el || !src) return 1;
    return el.clientWidth / src.w;
  };

  const onPointerDown = (e) => {
    if (!src) return;
    const el = stageRef.current;
    const r = el.getBoundingClientRect();
    const s = scaleOf();
    const sx = (e.clientX - r.left) / s;
    const sy = (e.clientY - r.top) / s;
    dragRef.current = { sx, sy };
    el.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e) => {
    if (!dragRef.current || !src) return;
    const el = stageRef.current;
    const r = el.getBoundingClientRect();
    const s = scaleOf();
    const cx = (e.clientX - r.left) / s;
    const cy = (e.clientY - r.top) / s;
    const { sx, sy } = dragRef.current;
    const x = Math.max(0, Math.min(sx, cx));
    const y = Math.max(0, Math.min(sy, cy));
    const w = Math.min(src.w, Math.max(sx, cx)) - x;
    const h = Math.min(src.h, Math.max(sy, cy)) - y;
    setRect({ x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) });
  };
  const onPointerUp = (e) => {
    dragRef.current = null;
    try { stageRef.current?.releasePointerCapture(e.pointerId); } catch (_) {}
  };

  const setField = (k, v) => {
    const n = Math.max(0, parseInt(v || '0', 10) || 0);
    setRect(r => ({ ...r, [k]: n }));
  };

  const crop = useCallback(async () => {
    if (!src || rect.w < 1 || rect.h < 1) return;
    try {
      const c = document.createElement('canvas');
      c.width = rect.w; c.height = rect.h;
      const g = c.getContext('2d');
      g.drawImage(src.img, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h);
      const blob = await canvasToBlob(c, 'image/png');
      const url = URL.createObjectURL(blob);
      setOut({ url, blob, filename: `${imgBaseName(src.file.name)}-cropped.png`, size: blob.size });
    } catch (e) { setErr(e.message); }
  }, [src, rect]);

  const s = scaleOf();

  return (
    <div className="imgt-panel">
      {!src ? (
        <ImgDropzone accept={op.accept} onFile={loadFile} />
      ) : (
        <div className="imgt-work">
          <div className="imgt-source">
            <div className="imgt-source-meta">
              <div className="imgt-source-name">{src.file.name}</div>
              <div className="imgt-source-dims">{src.w}×{src.h} px</div>
            </div>
            <button className="lh-chip" onClick={() => { setSrc(null); setOut(null); }}>
              <Icon name="close" />Change
            </button>
          </div>

          <div
            className="imgt-crop-stage"
            ref={stageRef}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
          >
            <img src={src.dataURL} alt="source" draggable={false} />
            {rect.w > 0 && rect.h > 0 && (
              <div className="imgt-crop-box" style={{
                left: rect.x * s, top: rect.y * s,
                width: rect.w * s, height: rect.h * s,
              }} />
            )}
          </div>
          <div className="imgt-hint" style={{ marginTop: -4 }}>Drag on the image to draw a crop region, or type exact pixels below.</div>

          <div className="imgt-crop-fields">
            {['x', 'y', 'w', 'h'].map(k => (
              <label key={k} className="imgt-num">
                <span>{k.toUpperCase()}</span>
                <input type="number" min="0" value={rect[k]}
                  onChange={e => setField(k, e.target.value)} />
              </label>
            ))}
            <button className="lh-btn primary" onClick={crop} disabled={rect.w < 1 || rect.h < 1}>
              <Icon name="crop" />Crop
            </button>
          </div>

          {err && <div className="imgt-error"><Icon name="error" />{err}</div>}
          {out && (
            <ImgResult
              url={out.url}
              sizeLabel={`${rect.w}×${rect.h} px · ${imgFormatBytes(out.size)}`}
              onDownload={() => imgDownload(out.blob, out.filename)}
            />
          )}
        </div>
      )}
    </div>
  );
}

// ============================================================
// Image → BASE64
// ============================================================
function ToBase64Panel({ op }) {
  const [src, setSrc] = useState(null);
  const [copied, setCopied] = useState(false);
  const [withPrefix, setWithPrefix] = useState(true);
  const [err, setErr] = useState(null);

  const loadFile = useCallback(async (file) => {
    setErr(null);
    try {
      const dataURL = await imgFileToDataURL(file);
      setSrc({ file, dataURL });
    } catch (e) { setErr(e.message); }
  }, []);

  const value = useMemo(() => {
    if (!src) return '';
    return withPrefix ? src.dataURL : src.dataURL.replace(/^data:[^;]+;base64,/, '');
  }, [src, withPrefix]);

  const copy = async () => {
    if (await copyText(value)) { setCopied(true); setTimeout(() => setCopied(false), 1500); }
  };

  return (
    <div className="imgt-panel">
      {!src ? (
        <ImgDropzone accept={op.accept} onFile={loadFile} />
      ) : (
        <div className="imgt-work">
          <div className="imgt-source">
            <div className="imgt-source-thumb"><img src={src.dataURL} alt="source" /></div>
            <div className="imgt-source-meta">
              <div className="imgt-source-name">{src.file.name}</div>
              <div className="imgt-source-dims">{imgFormatBytes(src.file.size)} · {value.length.toLocaleString()} chars</div>
            </div>
            <button className="lh-chip" onClick={() => setSrc(null)}>
              <Icon name="close" />Change
            </button>
          </div>

          <div className="imgt-controls">
            <label className="imgt-toggle">
              <input type="checkbox" checked={withPrefix} onChange={e => setWithPrefix(e.target.checked)} />
              <span>Include <code>data:</code> URI prefix</span>
            </label>
            <div style={{ flex: 1 }} />
            <button className="lh-btn primary" onClick={copy}>
              <Icon name={copied ? 'check' : 'content_copy'} />{copied ? 'Copied' : 'Copy'}
            </button>
          </div>

          <textarea className="imgt-textarea" readOnly value={value} spellCheck={false} />
          {err && <div className="imgt-error"><Icon name="error" />{err}</div>}
        </div>
      )}
    </div>
  );
}

// ============================================================
// BASE64 → Image
// ============================================================
function FromBase64Panel() {
  const [text, setText] = useState('');
  const [out, setOut] = useState(null);
  const [err, setErr] = useState(null);

  useEffect(() => () => { if (out?.url) URL.revokeObjectURL(out.url); }, [out]);

  const render = useCallback(async () => {
    setErr(null); setOut(null);
    const raw = text.trim();
    if (!raw) return;
    const srcUrl = raw.startsWith('data:') ? raw : `data:image/png;base64,${raw.replace(/\s+/g, '')}`;
    try {
      const img = await imgLoad(srcUrl);
      const c = document.createElement('canvas');
      c.width = img.naturalWidth; c.height = img.naturalHeight;
      c.getContext('2d').drawImage(img, 0, 0);
      const blob = await canvasToBlob(c, 'image/png');
      const url = URL.createObjectURL(blob);
      setOut({ url, blob, filename: `decoded-${Date.now()}.png`, size: blob.size, w: img.naturalWidth, h: img.naturalHeight });
    } catch (e) { setErr('That does not look like valid image data.'); }
  }, [text]);

  return (
    <div className="imgt-panel">
      <div className="imgt-work">
        <div className="imgt-controls">
          <span className="imgt-field-label">Paste a BASE64 string or a full <code>data:</code> URI</span>
          <div style={{ flex: 1 }} />
          {text && <button className="lh-chip" onClick={() => { setText(''); setOut(null); setErr(null); }}><Icon name="close" />Clear</button>}
        </div>
        <textarea
          className="imgt-textarea"
          placeholder="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…"
          value={text}
          onChange={e => setText(e.target.value)}
          spellCheck={false}
        />
        <div className="imgt-controls">
          <div style={{ flex: 1 }} />
          <button className="lh-btn primary" onClick={render} disabled={!text.trim()}>
            <Icon name="image" />Render image
          </button>
        </div>
        {err && <div className="imgt-error"><Icon name="error" />{err}</div>}
        {out && (
          <ImgResult
            url={out.url}
            sizeLabel={`${out.w}×${out.h} px · ${imgFormatBytes(out.size)}`}
            onDownload={() => imgDownload(out.blob, out.filename)}
          />
        )}
      </div>
    </div>
  );
}

// Decide an output format that preserves the source where possible.
function imgOutputType(file) {
  const t = (file?.type || '').toLowerCase();
  if (t === 'image/jpeg') return { type: 'image/jpeg', ext: 'jpg', q: 0.92 };
  if (t === 'image/webp') return { type: 'image/webp', ext: 'webp', q: 0.92 };
  return { type: 'image/png', ext: 'png', q: undefined };
}

// ============================================================
// Resize panel — exact pixels, optional aspect lock + % presets
// ============================================================
function ResizePanel({ op }) {
  const [src, setSrc] = useState(null);
  const [dim, setDim] = useState({ w: 0, h: 0 });
  const [lock, setLock] = useState(true);
  const [out, setOut] = useState(null);
  const [err, setErr] = useState(null);

  useEffect(() => () => { if (out?.url) URL.revokeObjectURL(out.url); }, [out]);

  const loadFile = useCallback(async (file) => {
    setErr(null); setOut(null);
    try {
      const dataURL = await imgFileToDataURL(file);
      const img = await imgLoad(dataURL);
      setSrc({ file, dataURL, img, w: img.naturalWidth, h: img.naturalHeight });
      setDim({ w: img.naturalWidth, h: img.naturalHeight });
    } catch (e) { setErr(e.message); }
  }, []);

  const ratio = src ? src.w / src.h : 1;
  const setW = (v) => {
    const w = Math.max(1, parseInt(v || '0', 10) || 0);
    setDim(lock ? { w, h: Math.max(1, Math.round(w / ratio)) } : d => ({ ...d, w }));
  };
  const setH = (v) => {
    const h = Math.max(1, parseInt(v || '0', 10) || 0);
    setDim(lock ? { w: Math.max(1, Math.round(h * ratio)), h } : d => ({ ...d, h }));
  };
  const applyPct = (pct) => {
    if (!src) return;
    setDim({ w: Math.max(1, Math.round(src.w * pct)), h: Math.max(1, Math.round(src.h * pct)) });
  };

  const run = useCallback(async () => {
    if (!src || dim.w < 1 || dim.h < 1) return;
    try {
      const fmt = imgOutputType(src.file);
      const c = document.createElement('canvas');
      c.width = dim.w; c.height = dim.h;
      const g = c.getContext('2d');
      g.imageSmoothingQuality = 'high';
      if (fmt.type === 'image/jpeg') { g.fillStyle = '#ffffff'; g.fillRect(0, 0, c.width, c.height); }
      g.drawImage(src.img, 0, 0, dim.w, dim.h);
      const blob = await canvasToBlob(c, fmt.type, fmt.q);
      const url = URL.createObjectURL(blob);
      setOut({ url, blob, filename: `${imgBaseName(src.file.name)}-${dim.w}x${dim.h}.${fmt.ext}`, size: blob.size, dims: `${dim.w}×${dim.h}` });
    } catch (e) { setErr(e.message); }
  }, [src, dim]);

  useEffect(() => { if (src) run(); }, [src, dim]); // eslint-disable-line

  return (
    <div className="imgt-panel">
      {!src ? (
        <ImgDropzone accept={op.accept} onFile={loadFile} />
      ) : (
        <div className="imgt-work">
          <div className="imgt-source">
            <div className="imgt-source-thumb"><img src={src.dataURL} alt="source" /></div>
            <div className="imgt-source-meta">
              <div className="imgt-source-name">{src.file.name}</div>
              <div className="imgt-source-dims">{src.w}×{src.h} px · {imgFormatBytes(src.file.size)}</div>
            </div>
            <button className="lh-chip" onClick={() => { setSrc(null); setOut(null); }}>
              <Icon name="close" />Change
            </button>
          </div>

          <div className="imgt-crop-fields">
            <label className="imgt-num">
              <span>WIDTH</span>
              <input type="number" min="1" value={dim.w} onChange={e => setW(e.target.value)} />
            </label>
            <button className={`imgt-lock ${lock ? 'is-on' : ''}`} onClick={() => setLock(l => !l)}
              title={lock ? 'Aspect ratio locked' : 'Aspect ratio unlocked'}>
              <Icon name={lock ? 'link' : 'link_off'} />
            </button>
            <label className="imgt-num">
              <span>HEIGHT</span>
              <input type="number" min="1" value={dim.h} onChange={e => setH(e.target.value)} />
            </label>
            <div className="imgt-pcts">
              {[0.25, 0.5, 0.75].map(p => (
                <button key={p} className="lh-chip" onClick={() => applyPct(p)}>{p * 100}%</button>
              ))}
              <button className="lh-chip" onClick={() => applyPct(1)}>Reset</button>
            </div>
          </div>

          {err && <div className="imgt-error"><Icon name="error" />{err}</div>}
          {out && (
            <ImgResult
              url={out.url}
              sizeLabel={`${out.dims} px · ${imgFormatBytes(out.size)}`}
              onDownload={() => imgDownload(out.blob, out.filename)}
            />
          )}
        </div>
      )}
    </div>
  );
}

// ============================================================
// Rotate & flip panel
// ============================================================
function RotatePanel({ op }) {
  const [src, setSrc] = useState(null);
  const [angle, setAngle] = useState(0);   // 0 | 90 | 180 | 270
  const [flipH, setFlipH] = useState(false);
  const [flipV, setFlipV] = useState(false);
  const [out, setOut] = useState(null);
  const [err, setErr] = useState(null);

  useEffect(() => () => { if (out?.url) URL.revokeObjectURL(out.url); }, [out]);

  const loadFile = useCallback(async (file) => {
    setErr(null); setOut(null); setAngle(0); setFlipH(false); setFlipV(false);
    try {
      const dataURL = await imgFileToDataURL(file);
      const img = await imgLoad(dataURL);
      setSrc({ file, dataURL, img, w: img.naturalWidth, h: img.naturalHeight });
    } catch (e) { setErr(e.message); }
  }, []);

  const run = useCallback(async () => {
    if (!src) return;
    try {
      const fmt = imgOutputType(src.file);
      const swap = angle === 90 || angle === 270;
      const cw = swap ? src.h : src.w;
      const ch = swap ? src.w : src.h;
      const c = document.createElement('canvas');
      c.width = cw; c.height = ch;
      const g = c.getContext('2d');
      if (fmt.type === 'image/jpeg') { g.fillStyle = '#ffffff'; g.fillRect(0, 0, cw, ch); }
      g.translate(cw / 2, ch / 2);
      g.rotate(angle * Math.PI / 180);
      g.scale(flipH ? -1 : 1, flipV ? -1 : 1);
      g.drawImage(src.img, -src.w / 2, -src.h / 2);
      const blob = await canvasToBlob(c, fmt.type, fmt.q);
      const url = URL.createObjectURL(blob);
      setOut({ url, blob, filename: `${imgBaseName(src.file.name)}-rotated.${fmt.ext}`, size: blob.size, dims: `${cw}×${ch}` });
    } catch (e) { setErr(e.message); }
  }, [src, angle, flipH, flipV]);

  useEffect(() => { if (src) run(); }, [src, angle, flipH, flipV]); // eslint-disable-line

  const rotateBy = (delta) => setAngle(a => ((a + delta) % 360 + 360) % 360);

  return (
    <div className="imgt-panel">
      {!src ? (
        <ImgDropzone accept={op.accept} onFile={loadFile} />
      ) : (
        <div className="imgt-work">
          <div className="imgt-source">
            <div className="imgt-source-thumb"><img src={src.dataURL} alt="source" /></div>
            <div className="imgt-source-meta">
              <div className="imgt-source-name">{src.file.name}</div>
              <div className="imgt-source-dims">{src.w}×{src.h} px · {imgFormatBytes(src.file.size)}</div>
            </div>
            <button className="lh-chip" onClick={() => { setSrc(null); setOut(null); }}>
              <Icon name="close" />Change
            </button>
          </div>

          <div className="imgt-rotate-bar">
            <button className="imgt-tbtn" onClick={() => rotateBy(-90)}><Icon name="rotate_left" />Left</button>
            <button className="imgt-tbtn" onClick={() => rotateBy(90)}><Icon name="rotate_right" />Right</button>
            <span className="imgt-tdiv" />
            <button className={`imgt-tbtn ${flipH ? 'is-on' : ''}`} onClick={() => setFlipH(v => !v)}><Icon name="swap_horiz" />Flip H</button>
            <button className={`imgt-tbtn ${flipV ? 'is-on' : ''}`} onClick={() => setFlipV(v => !v)}><Icon name="swap_vert" />Flip V</button>
            <span className="imgt-tdiv" />
            <span className="imgt-tstate">{angle}°{flipH ? ' · ↔' : ''}{flipV ? ' · ↕' : ''}</span>
          </div>

          {err && <div className="imgt-error"><Icon name="error" />{err}</div>}
          {out && (
            <ImgResult
              url={out.url}
              sizeLabel={`${out.dims} px · ${imgFormatBytes(out.size)}`}
              onDownload={() => imgDownload(out.blob, out.filename)}
            />
          )}
        </div>
      )}
    </div>
  );
}

// ============================================================
// Router for the active operation
// ============================================================
function ImgOpWorkspace({ op }) {
  if (op.kind === 'compress') return <CompressPanel op={op} />;
  if (op.kind === 'crop') return <CropPanel op={op} />;
  if (op.kind === 'resize') return <ResizePanel op={op} />;
  if (op.kind === 'rotate') return <RotatePanel op={op} />;
  if (op.kind === 'tobase64') return <ToBase64Panel op={op} />;
  if (op.kind === 'frombase64') return <FromBase64Panel />;
  return <ConvertPanel op={op} />;
}

// ============================================================
// Main tool
// ============================================================
function ImageTool() {
  const saved = useKvRecord('imageToolState') || {};
  const [active, setActive] = useState(null);

  useEffect(() => {
    if (active === null && saved.op !== undefined) setActive(saved.op);
  }, [saved.op]); // eslint-disable-line

  const open = (id) => { setActive(id); db.kv.put({ k: 'imageToolState', v: { op: id } }); };
  const back = () => { setActive(null); db.kv.put({ k: 'imageToolState', v: { op: null } }); };

  const op = active ? IMG_OP_BY_ID[active] : null;

  return (
    <div className="imgt-tool">
      {!op ? (
        <div className="imgt-landing">
          <div className="imgt-landing-head">
            <h1>Image tools</h1>
            <p>Convert, crop, compress and recolor images — all locally in your browser.</p>
          </div>
          <div className="imgt-grid">
            {IMG_OPS.map(o => (
              <button key={o.id} className="imgt-card" onClick={() => open(o.id)}>
                <span className="imgt-card-icon"><Icon name={o.icon} /></span>
                <span className="imgt-card-body">
                  <span className="imgt-card-title">{o.label}</span>
                  <span className="imgt-card-desc">{o.desc}</span>
                  <span className="imgt-card-tag">Image</span>
                </span>
              </button>
            ))}
          </div>
        </div>
      ) : (
        <div className="imgt-detail">
          <div className="imgt-detail-head">
            <button className="imgt-back" onClick={back}>
              <Icon name="arrow_back" />
            </button>
            <span className="imgt-card-icon sm"><Icon name={op.icon} /></span>
            <div className="imgt-detail-titles">
              <h2>{op.label}</h2>
              <p>{op.desc}</p>
            </div>
          </div>
          <ImgOpWorkspace op={op} />
        </div>
      )}
    </div>
  );
}

// ============================================================
// Standalone single-op page (used by per-tool sidebar entries)
// ============================================================
function ImageOpStandalone({ op }) {
  return (
    <div className="imgt-tool imgt-standalone">
      <div className="imgt-detail-head">
        <span className="imgt-card-icon sm"><Icon name={op.icon} /></span>
        <div className="imgt-detail-titles">
          <h2>{op.label}</h2>
          <p>{op.desc}</p>
        </div>
      </div>
      <ImgOpWorkspace op={op} />
    </div>
  );
}

window.ImageTool = ImageTool;
window.ImageOpStandalone = ImageOpStandalone;

// Expose one standalone component per op (e.g. window.ImageOp_resize) and a
// nav manifest that app.jsx splices into its TOOLS array so every image tool
// gets its own sidebar entry. All share the master `image` toggle via `group`.
IMG_OPS.forEach(op => {
  window['ImageOp_' + op.id] = function ImageOpPage() { return <ImageOpStandalone op={op} />; };
});
window.IMAGE_NAV = IMG_OPS.map(op => ({
  id: 'img-' + op.id,
  label: op.label,
  icon: op.icon,
  section: 'Image',
  component: 'ImageOp_' + op.id,
  sub: op.desc,
  group: 'image',
}));
