import 'dart:math' as math;
import 'dart:ui';

import 'package:image/image.dart' as img;

/// Refine an axis-aligned detection into a tilted card quadrilateral.
///
/// Returns corners ordered TL → TR → BR → BL in full-image pixel coords,
/// or `null` if refinement fails (caller should keep the AABB).
class CardCornerRefiner {
  /// [aabb] is the ML Kit box in the same pixel space as [image].
  static List<Offset>? refine(img.Image image, Rect aabb) {
    final pad = math.max(aabb.width, aabb.height) * 0.18;
    final roi = Rect.fromLTRB(
      (aabb.left - pad).clamp(0, image.width - 1.0),
      (aabb.top - pad).clamp(0, image.height - 1.0),
      (aabb.right + pad).clamp(1.0, image.width.toDouble()),
      (aabb.bottom + pad).clamp(1.0, image.height.toDouble()),
    );

    final x0 = roi.left.floor();
    final y0 = roi.top.floor();
    final rw = math.max(1, roi.width.ceil());
    final rh = math.max(1, roi.height.ceil());
    if (rw < 24 || rh < 24) return null;

    final gray = List<int>.filled(rw * rh, 0);
    final rgb = List<int>.filled(rw * rh * 3, 0);
    for (var y = 0; y < rh; y++) {
      final sy = (y0 + y).clamp(0, image.height - 1);
      for (var x = 0; x < rw; x++) {
        final sx = (x0 + x).clamp(0, image.width - 1);
        final p = image.getPixel(sx, sy);
        final i = y * rw + x;
        gray[i] = (0.299 * p.r + 0.587 * p.g + 0.114 * p.b).round();
        rgb[i * 3] = p.r.toInt();
        rgb[i * 3 + 1] = p.g.toInt();
        rgb[i * 3 + 2] = p.b.toInt();
      }
    }

    final cx = ((aabb.center.dx - x0).round()).clamp(0, rw - 1);
    final cy = ((aabb.center.dy - y0).round()).clamp(0, rh - 1);

    // Prefer flood-fill from ROI border (works on light tables); Otsu as backup.
    final candidates = <List<Offset>>[];
    final fromFlood = _quadFromMask(
      _foregroundViaBorderFlood(rgb, gray, rw, rh),
      rw,
      rh,
      cx,
      cy,
      x0,
      y0,
    );
    if (fromFlood != null) candidates.add(fromFlood);

    final fromOtsu = _quadFromMask(
      _foregroundViaOtsu(gray, rw, rh, cx, cy),
      rw,
      rh,
      cx,
      cy,
      x0,
      y0,
    );
    if (fromOtsu != null) candidates.add(fromOtsu);

    List<Offset>? best;
    var bestScore = -1.0;
    for (final q in candidates) {
      if (!_looksLikeFullCard(q, aabb)) continue;
      final score = _cardScore(q, aabb);
      if (score > bestScore) {
        bestScore = score;
        best = q;
      }
    }
    if (best == null) return null;

    // Slight inset so perspective crop doesn't include table fringe.
    return _padQuad(
      best,
      scale: 0.99,
      topExtra: 0.0,
      imageW: image.width.toDouble(),
      imageH: image.height.toDouble(),
    );
  }

  /// Tighten a loose AABB when tilted refine fails (axis-aligned shrink).
  static Rect? shrinkAabb(img.Image image, Rect aabb) {
    final pad = math.max(aabb.width, aabb.height) * 0.12;
    final roi = Rect.fromLTRB(
      (aabb.left - pad).clamp(0, image.width - 1.0),
      (aabb.top - pad).clamp(0, image.height - 1.0),
      (aabb.right + pad).clamp(1.0, image.width.toDouble()),
      (aabb.bottom + pad).clamp(1.0, image.height.toDouble()),
    );
    final x0 = roi.left.floor();
    final y0 = roi.top.floor();
    final rw = math.max(1, roi.width.ceil());
    final rh = math.max(1, roi.height.ceil());
    if (rw < 24 || rh < 24) return null;

    final gray = List<int>.filled(rw * rh, 0);
    final rgb = List<int>.filled(rw * rh * 3, 0);
    for (var y = 0; y < rh; y++) {
      final sy = (y0 + y).clamp(0, image.height - 1);
      for (var x = 0; x < rw; x++) {
        final sx = (x0 + x).clamp(0, image.width - 1);
        final p = image.getPixel(sx, sy);
        final i = y * rw + x;
        gray[i] = (0.299 * p.r + 0.587 * p.g + 0.114 * p.b).round();
        rgb[i * 3] = p.r.toInt();
        rgb[i * 3 + 1] = p.g.toInt();
        rgb[i * 3 + 2] = p.b.toInt();
      }
    }

    final cx = ((aabb.center.dx - x0).round()).clamp(0, rw - 1);
    final cy = ((aabb.center.dy - y0).round()).clamp(0, rh - 1);
    final mask = _foregroundViaBorderFlood(rgb, gray, rw, rh);
    final edge = _largestComponent(mask, rw, rh, cx, cy);
    if (edge == null || edge.length < 40) return null;

    var minX = edge.first.dx, maxX = edge.first.dx;
    var minY = edge.first.dy, maxY = edge.first.dy;
    for (final p in edge) {
      if (p.dx < minX) minX = p.dx;
      if (p.dx > maxX) maxX = p.dx;
      if (p.dy < minY) minY = p.dy;
      if (p.dy > maxY) maxY = p.dy;
    }

    // Map to image space and inset slightly.
    final insetX = (maxX - minX) * 0.01;
    final insetY = (maxY - minY) * 0.01;
    final tight = Rect.fromLTRB(
      x0 + minX + insetX,
      y0 + minY + insetY,
      x0 + maxX - insetX,
      y0 + maxY - insetY,
    );
    final area = tight.width * tight.height;
    final aabbArea = aabb.width * aabb.height;
    if (area < aabbArea * 0.28 || area > aabbArea * 1.2) return null;
    if (tight.width < 16 || tight.height < 16) return null;
    return tight.intersect(Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()));
  }

  static List<bool> _foregroundViaOtsu(
    List<int> gray,
    int rw,
    int rh,
    int cx,
    int cy,
  ) {
    final blurred = _boxBlur3(gray, rw, rh);
    final thr = _otsu(blurred);
    final mask = List<bool>.filled(rw * rh, false);
    for (var i = 0; i < blurred.length; i++) {
      mask[i] = blurred[i] >= thr;
    }
    if (!mask[cy * rw + cx]) {
      for (var i = 0; i < mask.length; i++) {
        mask[i] = !mask[i];
      }
    }
    return mask;
  }

  /// Mark table/background by flooding from ROI borders; leftover = card.
  static List<bool> _foregroundViaBorderFlood(
    List<int> rgb,
    List<int> gray,
    int rw,
    int rh,
  ) {
    // Median border luminance as table reference + RGB samples.
    final borderGray = <int>[];
    final borderRgb = <List<int>>[];
    void sample(int x, int y) {
      final i = y * rw + x;
      borderGray.add(gray[i]);
      borderRgb.add([rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]]);
    }

    for (var x = 0; x < rw; x++) {
      sample(x, 0);
      sample(x, rh - 1);
    }
    for (var y = 1; y < rh - 1; y++) {
      sample(0, y);
      sample(rw - 1, y);
    }
    borderGray.sort();
    final medG = borderGray[borderGray.length ~/ 2];

    // Adaptive color distance: table is usually uniform.
    var sumDev = 0.0;
    for (final g in borderGray) {
      sumDev += (g - medG).abs();
    }
    final mad = sumDev / borderGray.length;
    final grayTol = math.max(18.0, mad * 3.5).clamp(18.0, 42.0);
    const rgbTol = 48.0;

    bool looksLikeBg(int i) {
      final g = gray[i];
      if ((g - medG).abs() > grayTol) return false;
      final r = rgb[i * 3], gv = rgb[i * 3 + 1], b = rgb[i * 3 + 2];
      // Compare to a few border samples near same luminance.
      var best = double.infinity;
      for (var k = 0; k < borderRgb.length; k += math.max(1, borderRgb.length ~/ 24)) {
        final s = borderRgb[k];
        final d = (r - s[0]).abs() + (gv - s[1]).abs() + (b - s[2]).abs();
        if (d < best) best = d.toDouble();
      }
      return best <= rgbTol;
    }

    final bg = List<bool>.filled(rw * rh, false);
    final qx = List<int>.filled(rw * rh, 0);
    final qy = List<int>.filled(rw * rh, 0);
    var head = 0;
    var tail = 0;

    void trySeed(int x, int y) {
      final i = y * rw + x;
      if (bg[i] || !looksLikeBg(i)) return;
      bg[i] = true;
      qx[tail] = x;
      qy[tail] = y;
      tail++;
    }

    for (var x = 0; x < rw; x++) {
      trySeed(x, 0);
      trySeed(x, rh - 1);
    }
    for (var y = 1; y < rh - 1; y++) {
      trySeed(0, y);
      trySeed(rw - 1, y);
    }

    while (head < tail) {
      final x = qx[head];
      final y = qy[head];
      head++;
      for (final d in const [
        [-1, 0],
        [1, 0],
        [0, -1],
        [0, 1],
      ]) {
        final nx = x + d[0];
        final ny = y + d[1];
        if (nx < 0 || ny < 0 || nx >= rw || ny >= rh) continue;
        final ni = ny * rw + nx;
        if (bg[ni] || !looksLikeBg(ni)) continue;
        bg[ni] = true;
        qx[tail] = nx;
        qy[tail] = ny;
        tail++;
      }
    }

    // Foreground = not flooded background.
    final fg = List<bool>.filled(rw * rh, false);
    for (var i = 0; i < fg.length; i++) {
      fg[i] = !bg[i];
    }
    return fg;
  }

  static List<Offset>? _quadFromMask(
    List<bool> mask,
    int rw,
    int rh,
    int cx,
    int cy,
    int x0,
    int y0,
  ) {
    final component = _largestComponent(mask, rw, rh, cx, cy);
    if (component == null || component.length < 40) return null;

    final hull = _convexHull(component);
    if (hull.length < 3) return null;

    final quad = _minAreaRect(hull);
    if (quad == null) return null;

    return _orderCorners([
      for (final p in quad) Offset(x0 + p.dx, y0 + p.dy),
    ]);
  }

  /// Prefer portrait TCG aspect and tighter fit inside a possibly-loose AABB.
  static double _cardScore(List<Offset> q, Rect aabb) {
    final topW = (q[1] - q[0]).distance;
    final botW = (q[2] - q[3]).distance;
    final leftH = (q[3] - q[0]).distance;
    final rightH = (q[2] - q[1]).distance;
    final w = (topW + botW) / 2;
    final h = (leftH + rightH) / 2;
    final ratio = math.min(w, h) / math.max(w, h);
    const target = 63 / 88;
    final aspectScore = 1.0 - ((ratio - target).abs() / target).clamp(0.0, 1.0);
    final aabbArea = aabb.width * aabb.height;
    final area = _quadArea(q);
    // Reward filling a reasonable portion without needing to match a loose ML box.
    final fill = (area / aabbArea).clamp(0.0, 1.0);
    final fillScore = fill < 0.35 ? fill / 0.35 : (fill > 0.95 ? 0.7 : 1.0);
    return aspectScore * 0.6 + fillScore * 0.4;
  }

  /// TCG portrait ~63:88. Reject quads that only cover artwork / half a card.
  ///
  /// ML Kit AABBs are often oversized; do not require the quad to fill most of
  /// the AABB — only that it looks like a full portrait card near the box.
  static bool _looksLikeFullCard(List<Offset> q, Rect aabb) {
    final topW = (q[1] - q[0]).distance;
    final botW = (q[2] - q[3]).distance;
    final leftH = (q[3] - q[0]).distance;
    final rightH = (q[2] - q[1]).distance;
    final w = (topW + botW) / 2;
    final h = (leftH + rightH) / 2;
    if (w < 8 || h < 8) return false;

    final short = math.min(w, h);
    final long = math.max(w, h);
    final ratio = short / long;
    // Portrait card ≈ 0.72; half-art blobs are often nearer square (~0.9–1.0).
    if (ratio < 0.55 || ratio > 0.88) return false;

    final aabbArea = aabb.width * aabb.height;
    if (aabbArea < 1) return false;
    final area = _quadArea(q);
    // Allow tight cards inside loose ML boxes (was 0.55 — rejected good fits).
    if (area < aabbArea * 0.28 || area > aabbArea * 1.55) return false;

    var minY = q[0].dy, maxY = q[0].dy;
    var minX = q[0].dx, maxX = q[0].dx;
    for (final p in q) {
      if (p.dy < minY) minY = p.dy;
      if (p.dy > maxY) maxY = p.dy;
      if (p.dx < minX) minX = p.dx;
      if (p.dx > maxX) maxX = p.dx;
    }
    final quadBounds = Rect.fromLTRB(minX, minY, maxX, maxY);
    final inter = quadBounds.intersect(aabb);
    if (inter.width <= 0 || inter.height <= 0) return false;
    final boundsArea = quadBounds.width * quadBounds.height;
    if (boundsArea < 1) return false;
    final cover = (inter.width * inter.height) / boundsArea;
    // Quad should mostly sit inside the ML box (not a stray blob outside).
    if (cover < 0.55) return false;

    // Reject tiny upper-art-only locks relative to the detection box.
    if ((maxY - minY) < aabb.height * 0.45) return false;
    if ((maxX - minX) < aabb.width * 0.45) return false;

    // Center of quad should stay near AABB center (blocks half-side locks).
    final qcx = (minX + maxX) / 2;
    final qcy = (minY + maxY) / 2;
    if ((qcx - aabb.center.dx).abs() > aabb.width * 0.28) return false;
    if ((qcy - aabb.center.dy).abs() > aabb.height * 0.28) return false;

    return true;
  }

  /// Grow/shrink quad from its center; [topExtra] adds more margin on TL/TR.
  static List<Offset> _padQuad(
    List<Offset> q, {
    required double scale,
    required double topExtra,
    required double imageW,
    required double imageH,
  }) {
    final c = Offset(
      (q[0].dx + q[1].dx + q[2].dx + q[3].dx) / 4,
      (q[0].dy + q[1].dy + q[2].dy + q[3].dy) / 4,
    );
    final expanded = [
      for (final p in q)
        Offset(
          c.dx + (p.dx - c.dx) * scale,
          c.dy + (p.dy - c.dy) * scale,
        ),
    ];

    // Push top edge further along card height (away from bottom).
    final topMid = Offset(
      (expanded[0].dx + expanded[1].dx) / 2,
      (expanded[0].dy + expanded[1].dy) / 2,
    );
    final botMid = Offset(
      (expanded[2].dx + expanded[3].dx) / 2,
      (expanded[2].dy + expanded[3].dy) / 2,
    );
    final up = topMid - botMid;
    final bump = Offset(up.dx * topExtra, up.dy * topExtra);
    expanded[0] = expanded[0] + bump;
    expanded[1] = expanded[1] + bump;

    Offset clamp(Offset p) => Offset(
          p.dx.clamp(0.0, imageW - 1),
          p.dy.clamp(0.0, imageH - 1),
        );
    return expanded.map(clamp).toList(growable: false);
  }

  static List<int> _boxBlur3(List<int> src, int w, int h) {
    final tmp = List<int>.filled(w * h, 0);
    final out = List<int>.filled(w * h, 0);
    for (var y = 0; y < h; y++) {
      for (var x = 0; x < w; x++) {
        var s = 0;
        var n = 0;
        for (var dx = -1; dx <= 1; dx++) {
          final xx = x + dx;
          if (xx < 0 || xx >= w) continue;
          s += src[y * w + xx];
          n++;
        }
        tmp[y * w + x] = s ~/ n;
      }
    }
    for (var y = 0; y < h; y++) {
      for (var x = 0; x < w; x++) {
        var s = 0;
        var n = 0;
        for (var dy = -1; dy <= 1; dy++) {
          final yy = y + dy;
          if (yy < 0 || yy >= h) continue;
          s += tmp[yy * w + x];
          n++;
        }
        out[y * w + x] = s ~/ n;
      }
    }
    return out;
  }

  static int _otsu(List<int> gray) {
    final hist = List<int>.filled(256, 0);
    for (final v in gray) {
      hist[v.clamp(0, 255)]++;
    }
    final total = gray.length;
    var sum = 0;
    for (var i = 0; i < 256; i++) {
      sum += i * hist[i];
    }
    var sumB = 0;
    var wB = 0;
    var best = 0.0;
    var thr = 128;
    for (var t = 0; t < 256; t++) {
      wB += hist[t];
      if (wB == 0) continue;
      final wF = total - wB;
      if (wF == 0) break;
      sumB += t * hist[t];
      final mB = sumB / wB;
      final mF = (sum - sumB) / wF;
      final between = wB * wF * (mB - mF) * (mB - mF);
      if (between > best) {
        best = between;
        thr = t;
      }
    }
    return thr;
  }

  /// BFS largest component; prefer one covering seed if large enough.
  static List<Offset>? _largestComponent(
    List<bool> mask,
    int w,
    int h,
    int seedX,
    int seedY,
  ) {
    final seen = List<bool>.filled(w * h, false);
    List<Offset>? bestEdge;
    var bestCount = 0;
    List<Offset>? seededEdge;
    var seededCount = 0;
    final qx = List<int>.filled(w * h, 0);
    final qy = List<int>.filled(w * h, 0);

    for (var sy = 0; sy < h; sy++) {
      for (var sx = 0; sx < w; sx++) {
        final start = sy * w + sx;
        if (!mask[start] || seen[start]) continue;
        var head = 0;
        var tail = 0;
        qx[tail] = sx;
        qy[tail] = sy;
        tail++;
        seen[start] = true;
        final edgePts = <Offset>[];
        var containsSeed = false;
        var count = 0;
        while (head < tail) {
          final x = qx[head];
          final y = qy[head];
          head++;
          count++;
          var onEdge = false;
          for (final d in const [
            [-1, 0],
            [1, 0],
            [0, -1],
            [0, 1],
          ]) {
            final nx = x + d[0];
            final ny = y + d[1];
            if (nx < 0 ||
                ny < 0 ||
                nx >= w ||
                ny >= h ||
                !mask[ny * w + nx]) {
              onEdge = true;
              break;
            }
          }
          if (onEdge) edgePts.add(Offset(x.toDouble(), y.toDouble()));
          if (x == seedX && y == seedY) containsSeed = true;
          for (final d in const [
            [-1, 0],
            [1, 0],
            [0, -1],
            [0, 1],
          ]) {
            final nx = x + d[0];
            final ny = y + d[1];
            if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
            final ni = ny * w + nx;
            if (!mask[ni] || seen[ni]) continue;
            seen[ni] = true;
            qx[tail] = nx;
            qy[tail] = ny;
            tail++;
          }
        }
        if (edgePts.length < 8) continue;
        if (count > bestCount) {
          bestCount = count;
          bestEdge = edgePts;
        }
        if (containsSeed) {
          seededCount = count;
          seededEdge = edgePts;
        }
      }
    }

    if (seededEdge != null && seededCount >= bestCount * 0.35) {
      return seededEdge;
    }
    return bestEdge;
  }

  static List<Offset> _convexHull(List<Offset> pts) {
    if (pts.length <= 2) return List.of(pts);
    final sorted = [...pts]..sort((a, b) {
        final c = a.dx.compareTo(b.dx);
        return c != 0 ? c : a.dy.compareTo(b.dy);
      });
    // Deduplicate densely: subsample for speed on large blobs.
    final sample = <Offset>[];
    final step = math.max(1, sorted.length ~/ 800);
    for (var i = 0; i < sorted.length; i += step) {
      sample.add(sorted[i]);
    }
    if (sample.last != sorted.last) sample.add(sorted.last);

    double cross(Offset o, Offset a, Offset b) =>
        (a.dx - o.dx) * (b.dy - o.dy) - (a.dy - o.dy) * (b.dx - o.dx);

    final lower = <Offset>[];
    for (final p in sample) {
      while (lower.length >= 2 &&
          cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
        lower.removeLast();
      }
      lower.add(p);
    }
    final upper = <Offset>[];
    for (final p in sample.reversed) {
      while (upper.length >= 2 &&
          cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
        upper.removeLast();
      }
      upper.add(p);
    }
    lower.removeLast();
    upper.removeLast();
    return [...lower, ...upper];
  }

  /// Rotating-calipers style min-area rectangle from convex hull.
  static List<Offset>? _minAreaRect(List<Offset> hull) {
    if (hull.length < 3) return null;
    var bestArea = double.infinity;
    List<Offset>? best;

    for (var i = 0; i < hull.length; i++) {
      final a = hull[i];
      final b = hull[(i + 1) % hull.length];
      final edge = b - a;
      final len = edge.distance;
      if (len < 1e-6) continue;
      final ux = edge.dx / len;
      final uy = edge.dy / len;
      final vx = -uy;
      final vy = ux;

      var minU = double.infinity, maxU = -double.infinity;
      var minV = double.infinity, maxV = -double.infinity;
      for (final p in hull) {
        final u = (p.dx - a.dx) * ux + (p.dy - a.dy) * uy;
        final v = (p.dx - a.dx) * vx + (p.dy - a.dy) * vy;
        if (u < minU) minU = u;
        if (u > maxU) maxU = u;
        if (v < minV) minV = v;
        if (v > maxV) maxV = v;
      }
      final area = (maxU - minU) * (maxV - minV);
      if (area < bestArea) {
        bestArea = area;
        // Corners in edge-aligned space → image space.
        Offset corner(double u, double v) => Offset(
              a.dx + u * ux + v * vx,
              a.dy + u * uy + v * vy,
            );
        best = [
          corner(minU, minV),
          corner(maxU, minV),
          corner(maxU, maxV),
          corner(minU, maxV),
        ];
      }
    }
    return best;
  }

  static List<Offset> _orderCorners(List<Offset> corners) {
    assert(corners.length == 4);
    final c = Offset(
      (corners[0].dx + corners[1].dx + corners[2].dx + corners[3].dx) / 4,
      (corners[0].dy + corners[1].dy + corners[2].dy + corners[3].dy) / 4,
    );
    final sorted = [...corners]
      ..sort((a, b) {
        final aa = math.atan2(a.dy - c.dy, a.dx - c.dx);
        final bb = math.atan2(b.dy - c.dy, b.dx - c.dx);
        return aa.compareTo(bb);
      });

    // After atan2 sort (CCW from +x), pick the top-left-most as start.
    var start = 0;
    var bestScore = double.infinity;
    for (var i = 0; i < 4; i++) {
      final score = sorted[i].dx + sorted[i].dy;
      if (score < bestScore) {
        bestScore = score;
        start = i;
      }
    }
    return [
      sorted[start],
      sorted[(start + 1) % 4],
      sorted[(start + 2) % 4],
      sorted[(start + 3) % 4],
    ];
  }

  static double _quadArea(List<Offset> q) {
    // Shoelace.
    var s = 0.0;
    for (var i = 0; i < 4; i++) {
      final a = q[i];
      final b = q[(i + 1) % 4];
      s += a.dx * b.dy - b.dx * a.dy;
    }
    return s.abs() / 2;
  }
}
