#!/usr/bin/env python3
"""moon_detect.py — find the moon disk in a sky-cam frame.

Returns centroid, radius, and a quality score derived from:
  - roundness  (area / (pi * r^2))
  - isolation  (no comparable bright blob within IGNORE_RADIUS_PX)
  - sky        (low halo extent → clearer sky around the moon)

Used as a library:

  from moon_detect import detect_moon
  result = detect_moon('/path/to/frame.jpg')
  if result is not None:
      cx, cy = result['centroid']
      r      = result['radius']
      score  = result['quality']

CLI (handy for tuning):

  python3 moon_detect.py /path/to/frame.jpg
  python3 moon_detect.py /path/to/frame.jpg --debug debug.png

The detector ignores the bottom OVERLAY_PX rows because capture frames carry a
burnt-in timestamp that contains saturated pixels.
"""
from __future__ import annotations

import argparse
import json
import math
import sys
from dataclasses import dataclass

import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage

# --- Tuning constants --------------------------------------------------------
SATURATED_THRESHOLD = 240   # 0..255 — pixels at or above this count as "moon disk"
HALO_THRESHOLD = 100        # 0..255 — pixels above this count toward halo extent
OVERLAY_PX = 200            # bottom rows to ignore (timestamp overlay)
MIN_DIAMETER_PX = 10        # blob smaller than this is noise
MAX_DIAMETER_PX = 120       # blob bigger than this is probably the sun, not the moon
MIN_ROUNDNESS = 0.55        # area / (pi * r^2) — perfect circle = 1
ISOLATION_PX = 200          # other comparable blob this close → reject
# Halo extent — typical moon halo is ~3.5x disk radius; >5x means thick clouds
MAX_HALO_RATIO = 6.0


@dataclass
class MoonDetection:
    centroid_xy: tuple[float, float]
    radius_px: float
    diameter_px: float
    blob_pixels: int
    roundness: float
    halo_radius_px: float
    halo_ratio: float
    isolation_px: float
    quality: float

    def asdict(self) -> dict:
        return {
            'centroid': list(self.centroid_xy),
            'radius': self.radius_px,
            'diameter': self.diameter_px,
            'blob_pixels': self.blob_pixels,
            'roundness': self.roundness,
            'halo_radius': self.halo_radius_px,
            'halo_ratio': self.halo_ratio,
            'isolation': self.isolation_px,
            'quality': self.quality,
        }


def _grayscale_array(image_path: str) -> np.ndarray:
    im = Image.open(image_path).convert('L')
    return np.asarray(im)


def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray] | None:
    labels, n = ndimage.label(mask)
    if n == 0:
        return None
    sizes = ndimage.sum(mask, labels, range(1, n + 1))
    # Sort blobs by size descending; check roundness on the top few
    order = np.argsort(sizes)[::-1]
    for idx in order[:8]:
        label_id = idx + 1
        ys, xs = np.where(labels == label_id)
        if len(xs) < 4:
            continue
        bbw = xs.max() - xs.min() + 1
        bbh = ys.max() - ys.min() + 1
        diam = max(bbw, bbh)
        if diam < MIN_DIAMETER_PX or diam > MAX_DIAMETER_PX:
            continue
        radius = diam / 2.0
        roundness = len(xs) / (math.pi * radius * radius)
        if roundness < MIN_ROUNDNESS:
            continue
        return label_id, ys, xs
    return None


def detect_moon(image_path: str) -> MoonDetection | None:
    """Return MoonDetection or None if no acceptable moon is found."""
    a = _grayscale_array(image_path)
    if OVERLAY_PX > 0:
        a = a.copy()
        a[-OVERLAY_PX:, :] = 0
    mask = a >= SATURATED_THRESHOLD
    if not mask.any():
        return None

    found = _largest_round_blob(mask)
    if found is None:
        return None
    _, ys, xs = found

    cx = float(xs.mean())
    cy = float(ys.mean())
    blob_pixels = int(len(xs))
    bbw = xs.max() - xs.min() + 1
    bbh = ys.max() - ys.min() + 1
    diameter = float(max(bbw, bbh))
    radius = diameter / 2.0
    roundness = blob_pixels / (math.pi * radius * radius)

    # Isolation: any other saturated blob nearby of comparable size?
    labels_full, n_full = ndimage.label(mask)
    own_label = labels_full[int(round(cy)), int(round(cx))]
    isolation = float('inf')
    for label_id in range(1, n_full + 1):
        if label_id == own_label:
            continue
        ys2, xs2 = np.where(labels_full == label_id)
        if len(xs2) < blob_pixels * 0.3:
            continue
        d = math.hypot(xs2.mean() - cx, ys2.mean() - cy)
        if d < isolation:
            isolation = d
    if isolation < ISOLATION_PX:
        return None

    # Halo: connected component above HALO_THRESHOLD that contains the centroid
    halo_mask = a >= HALO_THRESHOLD
    halo_labels, _ = ndimage.label(halo_mask)
    halo_id = halo_labels[int(round(cy)), int(round(cx))]
    if halo_id == 0:
        halo_radius = radius
    else:
        yh, xh = np.where(halo_labels == halo_id)
        halo_radius = max(xh.max() - xh.min(), yh.max() - yh.min()) / 2.0
    halo_ratio = halo_radius / radius if radius > 0 else 1.0
    if halo_ratio > MAX_HALO_RATIO:
        return None  # too much glow → probably thick cloud cover

    # Quality score: roundness (0..1), low halo (1 = clear, 0 = thick cloud),
    # isolation factor (1 if very isolated, less if close to other lights).
    halo_clean = max(0.0, min(1.0, (MAX_HALO_RATIO - halo_ratio) / (MAX_HALO_RATIO - 1.5)))
    iso_factor = 1.0 if isolation == float('inf') else min(1.0, isolation / 600.0)
    quality = 0.5 * roundness + 0.35 * halo_clean + 0.15 * iso_factor

    return MoonDetection(
        centroid_xy=(cx, cy),
        radius_px=radius,
        diameter_px=diameter,
        blob_pixels=blob_pixels,
        roundness=roundness,
        halo_radius_px=halo_radius,
        halo_ratio=halo_ratio,
        isolation_px=isolation if isolation != float('inf') else -1.0,
        quality=quality,
    )


def _draw_debug(image_path: str, detection: MoonDetection | None, out_path: str):
    im = Image.open(image_path).convert('RGB')
    draw = ImageDraw.Draw(im)
    if detection is None:
        draw.text((20, 20), 'NO MOON DETECTED', fill='red')
    else:
        cx, cy = detection.centroid_xy
        r = detection.radius_px
        hr = detection.halo_radius_px
        draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline='yellow', width=4)
        draw.ellipse((cx - hr, cy - hr, cx + hr, cy + hr), outline='cyan', width=2)
        draw.text((20, 20), f'q={detection.quality:.2f} r={r:.1f} halo={hr:.1f}',
                  fill='yellow')
    im.save(out_path)


def _cli():
    p = argparse.ArgumentParser()
    p.add_argument('image')
    p.add_argument('--debug', help='write annotated PNG to this path')
    p.add_argument('--json', action='store_true', help='emit JSON for scripting')
    args = p.parse_args()

    det = detect_moon(args.image)
    if args.json:
        print(json.dumps(det.asdict() if det else None))
    elif det is None:
        print('no moon detected')
    else:
        d = det.asdict()
        for k, v in d.items():
            print(f'{k}={v}')
    if args.debug:
        _draw_debug(args.image, det, args.debug)
        print(f'debug image written: {args.debug}', file=sys.stderr)
    return 0 if det else 2


if __name__ == '__main__':
    sys.exit(_cli())
