# -*- coding: utf-8 -*-
"""
Halo Swatter  v1.0  -  Siril script
Copyright (c) 2026 André Duffeck. Released under the MIT License (see below).

Author:   André Duffeck
Contact:  andre@cloudyagain.com
Website:  https://cloudyagain.com/halo-swatter/
Requires: Siril 1.4 or newer (sirilpy), numpy, PyQt6

Reduces or removes star halos (reflection halos, glow around bright stars)
on mono and RGB images, with a live preview. Port of the PixInsight script
Halo Swatter 1.0; the algorithm and the controls are the same.

USAGE
  Click a star with a halo in the preview: a halo is added and its size found
  automatically (or drag from the star outward to set the size by hand). Every
  click on another star adds another halo. Click a halo's star to select it, drag
  its circle to resize it, Del removes it. Compare with the original (Space),
  then press Apply.

HOW IT WORKS (all automatic)
  - Background: a plane (level + gradient) fitted to a ring just outside the
    halo, ignoring stars and other outliers.
  - Halo profile: for every radius, the median brightness above that background
    over the full circle. Stars and nebula details inside the halo cover only a
    small part of each circle, so they don't end up in the model.
  - Support check: around the halo, the light actually present is compared with
    the profile. Where there is clearly less (fainter side of a lopsided halo,
    a dark lane) less is subtracted. This only ever reduces the correction, so
    neighbor stars can't cause dark patches.
  - Off-center halos: the light is modelled as STAR (round around the star,
    kept) + HALO (round around the halo's own center, removed). For halos with
    a clear edge the center comes from a circle fitted to the edge; for diffuse
    glows it is the point around which this model fits the light best.
  - Roundness: what remains of the star must be round around the star. A smooth
    leftover that is round around the halo center instead is a model error and
    is folded back into the halo profile.
  - The star's own light outside its core is fitted as a power law (how star
    wings fall off in linear data) and always kept; only the halo light on top is
    removed, also underneath the star. Saturated cores stay saturated.
  - Works best on LINEAR data (before stretching): there, halo light simply adds
    to the image, so subtracting it is exact.
  - Feather sets how softly the correction fades out towards the halo's edge.
  - Color images: modelled per channel. Optionally only the halo's tint is removed.

Halos are remembered between runs for images of the same size, so you can
Undo, start the script again and fine-tune.

MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions: The above copyright notice and this
permission notice shall be included in all copies or substantial portions of
the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""

import sys
import os
import json
import math
import time

import sirilpy as s

# Non-core dependencies are installed into Siril's Python environment on first start.
s.ensure_installed("numpy", "PyQt6")

import numpy as np                                                    # noqa: E402
from numpy.lib.stride_tricks import sliding_window_view               # noqa: E402
from PyQt6.QtCore import Qt, QTimer, QSettings, QPointF, QRectF       # noqa: E402
from PyQt6.QtGui import (QImage, QPainter, QPen, QColor,              # noqa: E402
                         QFontMetrics, QCursor)
from PyQt6.QtWidgets import (QApplication, QDialog, QWidget, QLabel,  # noqa: E402
                             QPushButton, QCheckBox, QComboBox, QGroupBox,
                             QSlider, QDoubleSpinBox, QTreeWidget,
                             QTreeWidgetItem, QHBoxLayout, QVBoxLayout,
                             QMessageBox, QSizePolicy, QAbstractItemView)

HR_TITLE = "Halo Swatter"
HR_VERSION = "1.0"
HR_AUTHOR = "André Duffeck"
HR_CONTACT = "andre@cloudyagain.com"
HR_WEBSITE = "https://cloudyagain.com/halo-swatter/"
HR_SETTINGS_ORG = "CloudyAgain"
HR_SETTINGS_APP = "HaloSwatter"
TWO_PI = 2 * math.pi

HR_MAX_ZOOM = 16      # display pixels per image pixel
HR_SLIDER_MAX = 200   # px: upper end of the size sliders (larger values still possible by dragging)
HR_QUANT = 1e-7       # noise floor from the data's quantization (set per image)

HR_BG_RING = 0.3      # background reference ring width, as fraction of the outer radius
HR_NA = 24            # angular cells of the "support" check
HR_NR = 6             # radial cells of the "support" check

# ============================================================================
# Math helpers
#
# The model is a line-by-line port of the PixInsight (JavaScript) version. Values
# that the JS version keeps in Float32Arrays are rounded to float32 here at the
# same places (r32), but all arithmetic runs in float64, as in JavaScript.
# Sums that decide between alternatives are accumulated in order (seqsum).
# ============================================================================

F64 = np.float64
F32 = np.float32


def r32(a):
    """Round to float32 precision, keep float64 dtype (like storing into a Float32Array)."""
    return np.asarray(a, dtype=F64).astype(F32).astype(F64)


def r32s(x):
    return float(np.float32(x))


def seqsum(a):
    """Left-to-right sum, like a JavaScript accumulation loop."""
    a = np.asarray(a, dtype=F64)
    if a.size == 0:
        return 0.0
    return float(np.cumsum(a)[-1])


def jsround(x):
    """JavaScript Math.round (halves round up)."""
    return int(math.floor(x + 0.5))


def clamp(v, a, b):
    return a if v < a else (b if v > b else v)


def smoothstep01(t):
    if t <= 0:
        return 0.0
    if t >= 1:
        return 1.0
    return t * t * (3 - 2 * t)


def mtf(m, x):
    """PixInsight midtones transfer function."""
    if x <= 0:
        return 0.0
    if x >= 1:
        return 1.0
    if m <= 0:
        return 1.0
    if m >= 1:
        return 0.0
    return (m - 1) * x / ((2 * m - 1) * x - m)


def mtf_vec(m, x):
    """mtf() for an array of x."""
    x = np.asarray(x, dtype=F64)
    if m <= 0:
        return np.where(x <= 0, 0.0, 1.0)
    if m >= 1:
        return np.where(x >= 1, 1.0, 0.0)
    with np.errstate(all="ignore"):
        y = (m - 1) * x / ((2 * m - 1) * x - m)
    return np.where(x <= 0, 0.0, np.where(x >= 1, 1.0, y))


def median_of(a):
    a = np.asarray(a, dtype=F64)
    if a.size == 0:
        return float("nan")
    return float(np.median(a))


def fill_nan(a):
    """In place: NaNs take the previous valid value (leading NaNs the first valid one)."""
    v = ~np.isnan(a)
    if not v.any():
        a[:] = 0
        return a
    first = int(np.argmax(v))
    idx = np.where(v, np.arange(a.size), 0)
    np.maximum.accumulate(idx, out=idx)
    a[first:] = a[idx[first:]]
    a[:first] = a[first]
    return a


def smooth_rows(A, k):
    """Edge-preserving smoothing of each row: running median of width 2k+1 (k=0 -> 3),
    then a light [1,2,1] pass when k > 0. Returns a new array (float32 values)."""
    A = np.asarray(A, dtype=F64)
    squeeze = (A.ndim == 1)
    if squeeze:
        A = A[None, :]
    n = A.shape[1]
    if n < 3:
        return A[0].copy() if squeeze else A.copy()
    hw = max(1, k)
    w = 2 * hw + 1
    t = np.empty_like(A)
    if n >= w:
        t[:, hw:n - hw] = np.median(sliding_window_view(A, w, axis=1), axis=2)
    edge = sorted(set(list(range(0, min(hw, n))) + list(range(max(hw, n - hw), n))))
    for i in edge:
        lo, hi = max(0, i - hw), min(n - 1, i + hw)
        t[:, i] = np.median(A[:, lo:hi + 1], axis=1)
    t = r32(t)
    if k > 0:
        tp = np.concatenate((t[:, :1], t[:, :-1]), axis=1)
        tn = np.concatenate((t[:, 1:], t[:, -1:]), axis=1)
        t = r32(0.25 * tp + 0.5 * t + 0.25 * tn)
    return t[0] if squeeze else t


def solve3(A, b):
    """3x3 linear system, Gaussian elimination with partial pivoting."""
    M = [[A[0][0], A[0][1], A[0][2], b[0]],
         [A[1][0], A[1][1], A[1][2], b[1]],
         [A[2][0], A[2][1], A[2][2], b[2]]]
    for c in range(3):
        p = c
        for r in range(c + 1, 3):
            if abs(M[r][c]) > abs(M[p][c]):
                p = r
        if abs(M[p][c]) < 1.0e-12:
            return None
        M[c], M[p] = M[p], M[c]
        for r2 in range(3):
            if r2 == c:
                continue
            f = M[r2][c] / M[c][c]
            for k in range(c, 4):
                M[r2][k] -= f * M[c][k]
    return [M[0][3] / M[0][0], M[1][3] / M[1][1], M[2][3] / M[2][2]]


def solve_n(A, b):
    """Small linear system (Gauss-Jordan). None if singular."""
    n = len(b)
    M = [list(A[r]) + [b[r]] for r in range(n)]
    for c in range(n):
        p = c
        for r in range(c + 1, n):
            if abs(M[r][c]) > abs(M[p][c]):
                p = r
        if abs(M[p][c]) < 1e-14:
            return None
        M[c], M[p] = M[p], M[c]
        for r in range(n):
            if r == c:
                continue
            f = M[r][c] / M[c][c]
            for k in range(c, n + 1):
                M[r][k] -= f * M[c][k]
    return [M[r][n] / M[r][r] for r in range(n)]


def fit_circle(xs, ys, ws):
    """Weighted algebraic (Kasa) circle fit."""
    A = [[0.0] * 3 for _ in range(3)]
    b = [0.0, 0.0, 0.0]
    for i in range(len(xs)):
        x, y, w = xs[i], ys[i], ws[i]
        z = x * x + y * y
        v = (x, y, 1.0)
        for r in range(3):
            for c in range(3):
                A[r][c] += w * v[r] * v[c]
            b[r] -= w * v[r] * z
    sol = solve3(A, b)
    if not sol:
        return None
    cx, cy = -sol[0] / 2, -sol[1] / 2
    rr = cx * cx + cy * cy - sol[2]
    if not (rr > 0):
        return None
    return (cx, cy, math.sqrt(rr))


def compute_stf(data, nc, clip, target):
    """Linked AutoSTF-style parameters: (c0, m)."""
    med_sum, mad_sum, used = 0.0, 0.0, 0
    for c in range(nc):
        d = data[c].ravel()
        n = d.size
        step = max(1, n // 250000)
        a = d[::step].astype(F64)
        a = a[~np.isnan(a)]
        if a.size < 10:
            continue
        med = float(np.median(a))
        med_sum += med
        mad_sum += 1.4826 * float(np.median(np.abs(a - med)))
        used += 1
    if used == 0:
        return (0.0, 0.5)
    M = med_sum / used
    D = max(mad_sum / used, 1.0e-5)
    c0 = clamp(M + clip * D, 0, 0.99)
    return (c0, mtf(target, M - c0))


# ============================================================================
# Halo definition
# ============================================================================

class Halo(object):
    def __init__(self, x, y, rOut):
        self.x = x            # halo center (detection may place it off the star)
        self.y = y
        self.sx = x           # star center: the star radius is measured from here
        self.sy = y
        self.rOut = rOut      # halo radius
        self.rIn = 0.25 * rOut  # star radius: kept untouched
        self.amount = 1.0
        self.feather = 0.1    # width of the fade into the background at the halo edge (0..1)
        self.chromaOnly = False  # color images: remove only the halo's tint
        self.model = None
        self.modelKey = ""

    def move_to(self, x, y):
        self.x = self.sx = x
        self.y = self.sy = y

    def key_string(self):
        return "|".join("%.2f" % v for v in (self.x, self.y, self.sx, self.sy, self.rOut, self.rIn))

    def to_plain(self):
        return {"x": self.x, "y": self.y, "sx": self.sx, "sy": self.sy, "rOut": self.rOut,
                "rIn": self.rIn, "amount": self.amount, "feather": self.feather,
                "chromaOnly": self.chromaOnly}


def halo_from_plain(o):
    h = Halo(float(o["x"]), float(o["y"]), float(o["rOut"]))
    if o.get("sx") is not None:
        h.sx = float(o["sx"])
        h.sy = float(o["sy"])
    if o.get("rIn") is not None:
        h.rIn = float(o["rIn"])
    if o.get("amount") is not None:
        h.amount = float(o["amount"])
    if o.get("feather") is not None and float(o["feather"]) <= 1:
        h.feather = float(o["feather"])
    h.chromaOnly = bool(o.get("chromaOnly", False))
    return h


# ============================================================================
# Halo model
#
#   1. Background: a plane (brightness + gradient) fitted to a ring just outside
#      the halo, with stars and other outliers rejected iteratively.
#   2. Halo profile: for each radius, the MEDIAN of (pixel - background) over the
#      full circle. Stars and nebula details inside the halo occupy only a small
#      part of each circle, so they don't affect it.
#   3. Support check: in 24 x 6 cells around the halo, the light actually present
#      is compared with the profile. Where a cell has clearly less light (fainter
#      side of a lopsided halo, dark lane) less is subtracted there. This can only
#      ever REDUCE the correction, never increase it, so bright objects inside the
#      halo (neighbor stars) can't cause dark patches.
# ============================================================================

def fit_plane_robust(u, w, v):
    """Robust plane fit v = a + b*u + c*w, rejecting outliers. Returns (a, b, c, sigma)."""
    u = np.asarray(u, dtype=F64)
    w = np.asarray(w, dtype=F64)
    v = np.asarray(v, dtype=F64)
    if v.size > 6000:   # 3 parameters don't need more than a few thousand samples
        st = int(math.ceil(v.size / 4000))
        u, w, v = u[::st], w[::st], v[::st]
    keep = np.ones(v.size, dtype=bool)
    coef = [median_of(v), 0.0, 0.0]
    sigma = 0.0
    for _ in range(4):
        m = int(keep.sum())
        if m < 10:
            break
        uk, wk, vk = u[keep], w[keep], v[keep]
        su, sw = seqsum(uk), seqsum(wk)
        A = [[float(m), su, sw],
             [su, seqsum(uk * uk), seqsum(uk * wk)],
             [sw, seqsum(wk * uk), seqsum(wk * wk)]]
        b = [seqsum(vk), seqsum(uk * vk), seqsum(wk * vk)]
        sol = solve3(A, b)
        if not sol:
            break
        coef = sol
        res = v - (coef[0] + coef[1] * u + coef[2] * w)
        sigma = max(1.4826 * median_of(np.abs(res[keep])), 1e-6)
        keep = (res < 2.5 * sigma) & (res > -3.5 * sigma)
    return (coef[0], coef[1], coef[2], sigma)


def bin_medians(keys, vals, nb, min_count):
    """Median per bin (keys = bin index, -1 skips). float32 values, NaN = empty."""
    keys = np.asarray(keys)
    vals = np.asarray(vals, dtype=F64)
    out = np.full(nb, np.nan, dtype=F64)
    m = (keys >= 0) & (keys < nb)
    k = keys[m].astype(np.int64)
    if k.size == 0:
        return out
    v = vals[m]
    order = np.lexsort((v, k))
    vs = v[order]
    cnt = np.bincount(k, minlength=nb)
    start = np.concatenate(([0], np.cumsum(cnt)[:-1]))
    ok = (cnt >= min_count) & (cnt > 0)
    b = np.nonzero(ok)[0]
    if b.size:
        c = cnt[b]
        h = c // 2
        hi = vs[start[b] + h]
        lo = vs[start[b] + np.maximum(h - 1, 0)]
        out[b] = np.where((c & 1) == 1, hi, 0.5 * (hi + lo))
    return r32(out)


def bin_counts(keys, nb):
    keys = np.asarray(keys)
    k = keys[(keys >= 0) & (keys < nb)].astype(np.int64)
    return np.bincount(k, minlength=nb)


_BETAS = []
_b = 2.0
while _b <= 6.001:          # star wings fall off at least as r^-2 (same float steps as the JS loop)
    _BETAS.append(_b)
    _b += 0.05
_BETAS = np.array(_BETAS, dtype=F64)
del _b


def fit_star_model(rs, vals, rIn, rFit, sigma):
    """The star's own light outside its core: a power law A*rs^-beta, fitted together with
    a constant C that takes up the halo light near the star. Returns (A, beta), A = 0: no fit."""
    nb = 30
    bwS = max(0.5, (rFit - rIn) / nb)
    t = (np.asarray(rs, dtype=F64) - rIn) / bwS
    keys = np.where((t >= 0) & (t < nb), np.floor(np.where(np.isfinite(t), t, -1)), -1).astype(np.int64)
    P = bin_medians(keys, vals, nb, 3)
    rb, pb, wb = [], [], []
    for b in range(nb):
        p = P[b]
        if not (p == p) or p > 0.9:       # empty or saturated
            continue
        rb.append(rIn + (b + 0.5) * bwS)
        pb.append(p)
        e = max(abs(p), 2 * sigma)
        wb.append(1 / (e * e))            # relative errors: the steep part counts
    if len(rb) < 5:
        return (0.0, 2.0)
    rb = np.array(rb, dtype=F64)
    pb = np.array(pb, dtype=F64)
    wb = np.array(wb, dtype=F64)
    with np.errstate(all="ignore"):
        f = np.power(rb[None, :], -_BETAS[:, None])
        cs = lambda a: np.cumsum(a, axis=1)[:, -1]
        s11 = cs(wb * f * f)
        s12 = cs(wb * f)
        s22 = float(np.cumsum(wb)[-1])
        t1 = cs(wb * f * pb)
        t2 = float(np.cumsum(wb * pb)[-1])
        det = s11 * s22 - s12 * s12
        A = (t1 * s22 - t2 * s12) / det
        C = (s11 * t2 - s12 * t1) / det
        neg = A < 0
        A = np.where(neg, 0.0, A)
        C = np.where(neg, t2 / s22, C)
        d = pb[None, :] - (A[:, None] * f + C[:, None])
        sse = cs(wb * d * d)
    sse = np.where(np.abs(det) > 0, sse, np.inf)
    sse = np.where(np.isnan(sse), np.inf, sse)
    if not np.isfinite(sse).any():
        return (0.0, 2.0)
    i = int(np.argmin(sse))
    return (float(A[i]), float(_BETAS[i]))


def star_model_at(m, rs, rIn):
    A, beta = m
    rs = np.asarray(rs, dtype=F64)
    if not (A > 0):
        return np.zeros(rs.shape, dtype=F64)
    with np.errstate(all="ignore"):
        return A * np.power(np.maximum(rs, rIn), -beta)


def star_fit_range(rIn, R):
    return clamp(2.5 * rIn, rIn + 6, max(rIn + 8, 0.6 * R))   # the steep part next to the core


def _lum(img, nc, ys, xs):
    """Luminance (channel mean, float64) of the pixel grid ys x xs."""
    if nc == 1:
        return img[0][np.ix_(ys, xs)].astype(F64)
    L = img[0][np.ix_(ys, xs)].astype(F64)
    for c in range(1, nc):
        L = L + img[c][np.ix_(ys, xs)]
    return L / nc


class GeometrySet(object):
    pass


def collect_geometry_set(img, nc, sx, sy, R, rIn):
    """Luminance samples around the star, background-subtracted, star core excluded."""
    H, W = img.shape[1], img.shape[2]
    Rs, Re = 1.75 * R, 1.45 * R
    x0, x1 = max(0, int(math.floor(sx - Rs))), min(W, int(math.ceil(sx + Rs)))
    y0, y1 = max(0, int(math.floor(sy - Rs))), min(H, int(math.ceil(sy + Rs)))
    if x1 - x0 < 8 or y1 - y0 < 8:
        return None
    stride = max(1, int(math.ceil(math.sqrt((x1 - x0) * (y1 - y0) / 12000))))
    Rs2 = Rs * Rs
    ys = np.arange(y0, y1, stride)
    dy = ys + 0.5 - sy
    ys, dy = ys[~(dy * dy > Rs2)], dy[~(dy * dy > Rs2)]
    xs = np.arange(x0, x1, stride)
    dx = xs + 0.5 - sx
    if ys.size == 0:
        return None
    DX = np.broadcast_to(dx[None, :], (ys.size, xs.size))
    DY = np.broadcast_to(dy[:, None], (ys.size, xs.size))
    r2 = DX * DX + DY * DY
    ok = ~(r2 > Rs2)
    L = _lum(img, nc, ys, xs)
    r = np.sqrt(r2)
    ring = ok & (r >= Re)
    inner = ok & (r < Re) & (r >= rIn)
    ringU, ringW, ringV = DX[ring] / R, DY[ring] / R, L[ring]
    ex, ey, ev = DX[inner], DY[inner], L[inner]
    if ringV.size < 30 or ev.size < 50:
        return None
    p = fit_plane_robust(ringU, ringW, ringV)
    rs = r32(np.sqrt(ex * ex + ey * ey))
    sm = (0.0, 2.0)
    e = None
    for pas in range(2):
        e = r32(ev - (p[0] + p[1] * ex / R + p[2] * ey / R))
        sm = fit_star_model(rs, e, rIn, star_fit_range(rIn, R), max(p[3], 1e-7))
        if pas == 1 or not (sm[0] > 0):
            break
        # background again, without the star's wing
        rr = np.sqrt(ringU * ringU + ringW * ringW) * R
        p = fit_plane_robust(ringU, ringW, ringV - star_model_at(sm, rr, rIn))
    g = GeometrySet()
    g.ex, g.ey, g.e, g.rs = ex, ey, e, rs
    g.Sv = r32(star_model_at(sm, rs, rIn))
    g.sm, g.sigma, g.R, g.Re, g.rIn, g.stride = sm, max(p[3], 1e-7), R, Re, rIn, stride
    return g


def fit_two_part(gs, cx, cy, R):
    """Fit STAR + HALO for halo center (cx, cy) (relative to the star) and halo radius R.
    Returns (score, H, bw), lower score = better."""
    n = gs.e.size
    bw = max(1, gs.Re / 40)
    nbH = int(math.ceil(R / bw)) + 1
    dx, dy = gs.ex - cx, gs.ey - cy
    rc = np.sqrt(dx * dx + dy * dy)
    kH = np.where(rc < R, np.floor(rc / bw), -1).astype(np.int64)
    has = kH >= 0
    kHs = np.where(has, kH, 0)
    Sv = gs.Sv
    Hp = None
    for it in range(2):
        v = r32(gs.e - Sv)
        Hp = bin_medians(kH, v, nbH, 3)
        fill_nan(Hp)
        Hp[~(Hp > 0)] = 0
        if it == 1 or not (gs.sm[0] > 0):
            break
        # refit the star on what the halo doesn't explain
        v = r32(gs.e - np.where(has, Hp[kHs], 0))
        sm = fit_star_model(gs.rs, v, gs.rIn, star_fit_range(gs.rIn, R), gs.sigma)
        if sm[0] > 0:
            Sv = r32(star_model_at(sm, gs.rs, gs.rIn))
    clip = 3 * gs.sigma
    res = np.abs(gs.e - Sv - np.where(has, Hp[kHs], 0))
    score = seqsum(np.where(res < clip, res, clip))
    return (score / n, Hp, bw)


def search_halo_center(gs, cx0, cy0, R, maxOff):
    """Search the halo center around (cx0, cy0) (relative to the star), within maxOff*R.
    Only moves away from the star if that fits clearly better."""
    lim = maxOff * R
    best = [cx0, cy0, fit_two_part(gs, cx0, cy0, R)[0]]
    steps = [0.12, 0.05, 0.02, 0.008]
    for k in range(len(steps)):
        st = steps[k] * R
        span = 2 if k == 0 else 1
        bx, by = best[0], best[1]
        for j in range(-span, span + 1):
            for i in range(-span, span + 1):
                if i == 0 and j == 0:
                    continue
                x, y = bx + i * st, by + j * st
                if x * x + y * y > lim * lim:
                    continue
                sc = fit_two_part(gs, x, y, R)[0]
                if sc < best[2]:
                    best = [x, y, sc]
    s0 = fit_two_part(gs, 0, 0, R)[0]
    if not (best[2] < s0 * 0.985):
        best = [0.0, 0.0, s0]     # no clear gain: keep it on the star
    return best


def halo_extent(H, bw, rStart):
    """Extent of a halo from its profile: where it has faded to ~8% of its strength."""
    nb = H.size
    b0 = min(nb - 1, int(math.ceil(rStart / bw)))
    Hs = smooth_rows(r32(H), 1)
    peak = 0.0
    for b in range(b0, nb):
        if Hs[b] > peak:
            peak = Hs[b]
    if not (peak > 0):
        return 0
    bp = b0
    for b in range(b0, nb):
        if Hs[b] == peak:
            bp = b
            break
    for b in range(bp, nb):
        if Hs[b] < 0.08 * peak:
            return (b + 0.5) * bw
    return 0   # never faded within the search area: no reliable extent


def find_halo_geometry(img, nc, h, seedX, seedY, R, rMax):
    """Find the halo's own center (and, for diffuse glows without a sharp edge, its size).
    Returns (x, y, R) or None."""
    rIn = max(2, h.rIn)
    if not (R > 0):
        # Diffuse glow: fit within a search radius, read the extent off the profile.
        # Start at ~8x the star core and widen if the glow hasn't faded out inside it.
        Rg = clamp(8 * rIn, 40, rMax)
        ext = 0
        c0 = None
        for _ in range(4):
            set0 = collect_geometry_set(img, nc, h.sx, h.sy, Rg, rIn)
            if not set0:
                return None
            c0 = search_halo_center(set0, seedX - h.sx, seedY - h.sy, Rg, 0.3)
            f0 = fit_two_part(set0, c0[0], c0[1], Rg)
            ext = halo_extent(f0[1], f0[2], rIn + math.sqrt(c0[0] * c0[0] + c0[1] * c0[1]))
            if ext > 0 and ext < 0.8 * Rg:
                break   # faded out well inside: done
            if Rg >= rMax:
                break
            Rg = min(rMax, 1.8 * Rg)
        if not (ext > 2 * rIn):
            return None
        R = ext * 1.1
        seedX = h.sx + c0[0]
        seedY = h.sy + c0[1]
    gs = collect_geometry_set(img, nc, h.sx, h.sy, R, rIn)
    if not gs:
        return None
    c = search_halo_center(gs, seedX - h.sx, seedY - h.sy, R, 0.45)
    return (h.sx + c[0], h.sy + c[1], R)


class HaloModel(object):
    pass


def build_halo_model(img, nc, h):
    H_, W = img.shape[1], img.shape[2]
    R = h.rOut
    Rb = R + max(4, HR_BG_RING * R)
    x0, x1 = max(0, int(math.floor(h.x - Rb))), min(W, int(math.ceil(h.x + Rb)))
    y0, y1 = max(0, int(math.floor(h.y - Rb))), min(H_, int(math.ceil(h.y + Rb)))
    if x1 - x0 < 5 or y1 - y0 < 5 or R < 3:
        return None
    # Speed: at most ~400000 samples (every pixel for halos up to a radius of about 250).
    # With 40000 the noise left in the per-radius medians showed as rings in large halos.
    # Right next to the star (where the star's profile is fitted) every pixel is used.
    stride = max(1, int(math.ceil(math.sqrt((x1 - x0) * (y1 - y0) / 400000))))
    rIn = h.rIn
    rFit = star_fit_range(rIn, R)
    nearR = 1.15 * rFit + 2
    nearR2 = nearR * nearR
    Rb2, R2 = Rb * Rb, R * R

    # ---- Collect samples -------------------------------------------------
    yy = np.arange(y0, y1)
    dy = yy + 0.5 - h.y
    sdy = yy + 0.5 - h.sy
    rowStrided = ((yy - y0) % stride) == 0
    rowNear = sdy * sdy < nearR2
    rowOK = ~(dy * dy > Rb2) & (rowStrided | rowNear)
    yy, dy, sdy, rowStrided, rowNear = yy[rowOK], dy[rowOK], sdy[rowOK], rowStrided[rowOK], rowNear[rowOK]
    if yy.size == 0:
        return None
    xx = np.arange(x0, x1)
    dx = xx + 0.5 - h.x
    sdx = xx + 0.5 - h.sx
    colStrided = ((xx - x0) % stride) == 0
    shape = (yy.size, xx.size)
    DX = np.broadcast_to(dx[None, :], shape)
    DY = np.broadcast_to(dy[:, None], shape)
    SDX = np.broadcast_to(sdx[None, :], shape)
    SDY = np.broadcast_to(sdy[:, None], shape)
    strided = rowStrided[:, None] & colStrided[None, :]
    sr2 = SDX * SDX + SDY * SDY
    near = rowNear[:, None] & (sr2 < nearR2)
    r2 = DX * DX + DY * DY
    valid = (strided | near) & ~(r2 > Rb2)
    mNear = valid & near & (r2 < R2)
    mRing = valid & strided & (r2 >= R2)
    mIn = valid & strided & (r2 < R2)
    pix = [img[c][np.ix_(yy, xx)] for c in range(nc)]

    nR = np.sqrt(r2[mNear])
    nRS = np.sqrt(sr2[mNear])
    nU, nW = DX[mNear] / R, DY[mNear] / R
    nV = [pix[c][mNear].astype(F64) for c in range(nc)]
    ringU, ringW = DX[mRing] / R, DY[mRing] / R
    ringV = [pix[c][mRing].astype(F64) for c in range(nc)]
    inDX, inDY = DX[mIn], DY[mIn]
    inR = np.sqrt(r2[mIn])
    inA = np.arctan2(inDY, inDX)
    inA = np.where(inA < 0, inA + TWO_PI, inA)
    inU, inW = inDX / R, inDY / R
    inV = [pix[c][mIn].astype(F64) for c in range(nc)]
    if ringU.size < 20 or inR.size < 20:
        return None

    # ---- Background plane + star --------------------------------------------
    # In linear data a bright star's wing still adds light in the background ring, so
    # background and star are fitted together: plane -> star -> plane without the star.
    dsx0, dsy0 = h.sx - h.x, h.sy - h.y
    gx, gy = ringU * R - dsx0, ringW * R - dsy0
    ringRS = r32(np.sqrt(gx * gx + gy * gy))
    planes, stars = [], []
    for c in range(nc):
        pl = fit_plane_robust(ringU, ringW, ringV[c])
        sm = (0.0, 2.0)
        for _ in range(2):
            rcs = r32(nV[c] - (pl[0] + pl[1] * nU + pl[2] * nW))
            sm = fit_star_model(nRS, rcs, rIn, rFit, max(pl[3], 1e-7))
            if not (sm[0] > 0):
                break
            rv = r32(ringV[c] - star_model_at(sm, ringRS, rIn))
            pl = fit_plane_robust(ringU, ringW, rv)
        planes.append(pl)
        stars.append(sm)

    # ---- Halo profile (two-part: star + halo) ---------------------------------
    # STAR = power law around the star (kept), HALO = radial profile around the halo
    # center (removed). The star core (inside the star radius) is left out.
    bw = max(0.5, R / 100)
    nb = int(math.ceil(R / bw)) + 1
    dsx, dsy = h.sx - h.x, h.sy - h.y
    dStar = math.sqrt(dsx * dsx + dsy * dsy)
    nbS = int(math.ceil((R + dStar) / bw)) + 1
    kHn = np.where(nRS < rIn, -1, np.minimum(nb - 1, np.floor(nR / bw))).astype(np.int64)
    hasN = kHn >= 0
    kHns = np.where(hasN, kHn, 0)
    qx, qy = inU * R - dsx, inW * R - dsy
    inRS = r32(np.sqrt(qx * qx + qy * qy))
    core = inRS < rIn
    kH = np.where(core, -1, np.minimum(nb - 1, np.floor(inR / bw))).astype(np.int64)
    kS = np.where(core, -1, np.minimum(nbS - 1, np.floor(inRS / bw))).astype(np.int64)
    hasH, hasS = kH >= 0, kS >= 0
    kHs, kSs = np.where(hasH, kH, 0), np.where(hasS, kS, 0)
    M, res = [], []
    for c in range(nc):
        p = planes[c]
        e0 = r32(inV[c] - (p[0] + p[1] * inU + p[2] * inW))
        smc = stars[c]
        prof = None
        for it in range(3):
            # STAR: power law around the star, fitted outside its core (kept, never subtracted)
            rc = r32(e0 - star_model_at(smc, inRS, rIn))
            # HALO: radial median of the rest around the halo center; under the star core
            # (no data) it continues at the level next to it
            prof = bin_medians(kH, rc, nb, 3)
            fill_nan(prof)
            if it == 2 or not (smc[0] > 0):
                break
            # refit the star on what the halo doesn't explain (full-resolution set near the star)
            svn = r32(nV[c] - (p[0] + p[1] * nU + p[2] * nW) - np.where(hasN, np.maximum(prof[kHns], 0), 0))
            sm2 = fit_star_model(nRS, svn, rIn, rFit, max(p[3], 1e-7))
            if sm2[0] > 0:
                smc = sm2
        # Roundness correction: what remains of the star must be round around the star.
        # A smooth non-round leftover that is itself round around the HALO center is an
        # error of the halo profile (e.g. slightly wrong center or size): fold it back in.
        # Nebula structure follows neither circle and is not affected.
        if dStar >= 2:
            for _ in range(3):
                rc = r32(e0 - np.where(hasH, prof[kHs], 0))
                T = bin_medians(kS, rc, nbS, 3)          # round part around the star
                fill_nan(T)
                sv = r32(np.where(hasS, rc - T[kSs], 0))
                dlt = bin_medians(kH, sv, nb, 5)         # its part that is round around the halo center
                fill_nan(dlt)
                dlt = smooth_rows(dlt, 2)
                prof = r32(np.maximum(0, prof + dlt))
                maxD = float(np.max(np.abs(dlt))) if dlt.size else 0.0
                if maxD < 0.5 * p[3] / math.sqrt(50):
                    break   # converged
        rc = r32(e0 - star_model_at(smc, inRS, rIn))
        prof = smooth_rows(prof, 1)
        prof[~(prof > 0)] = 0
        M.append(prof)
        res.append(rc)

    # ---- Support check (cells) ------------------------------------------------
    rc0 = min(rIn, 0.6 * R)
    crw = max(2 * bw, (R - rc0) / HR_NR)
    NR = max(1, min(HR_NR, jsround((R - rc0) / crw)))
    K = []
    ncell = HR_NA * NR
    outside = inR < rc0
    ia = np.minimum(HR_NA - 1, np.floor(inA / TWO_PI * HR_NA))
    ir = np.minimum(NR - 1, np.floor((inR - rc0) / crw))
    ck = np.where(outside, -1, ia * NR + ir).astype(np.int64)
    t = inR / bw - 0.5
    b0 = np.clip(np.floor(t), 0, nb - 1).astype(np.int64)
    b1 = np.minimum(nb - 1, b0 + 1)
    fr = np.clip(t - b0, 0, 1)
    cCnt = bin_counts(ck, ncell)
    for c in range(nc):
        cm = r32(np.where(outside, 0, M[c][b0] * (1 - fr) + M[c][b1] * fr))
        cRes = bin_medians(ck, res[c], ncell, 1)
        cMod = bin_medians(ck, cm, ncell, 1)
        sigma = planes[c][3]
        k = np.ones(ncell, dtype=F64)
        for i in range(ncell):
            cnt = int(cCnt[i])
            mm = cMod[i] if cnt > 0 else 0
            if cnt < 8 or not (mm > 0):
                continue
            ratio = cRes[i] / mm
            margin = 1.5 * 1.25 * sigma / math.sqrt(cnt) / mm   # noise allowance
            k[i] = clamp(ratio + margin, 0, 1)
        k = r32(k)
        # light angular smoothing (after clamping, so a star-lifted cell can't lift its neighbors)
        kk = k.reshape(HR_NA, NR)
        k = r32(0.25 * np.roll(kk, 1, axis=0) + 0.5 * kk + 0.25 * np.roll(kk, -1, axis=0)).ravel()
        K.append(k)

    m = HaloModel()
    m.bw, m.nb, m.M, m.K, m.NR, m.rc0, m.crw = bw, nb, M, K, NR, rc0, crw
    return m


def sat_factor(v):
    """Saturated star cores stay saturated: the correction fades out above ~95% brightness."""
    return np.clip((0.995 - v) / 0.045, 0, 1)


def eval_halo(h, px, py, nc, chromaOnly):
    """Amount of light to subtract at the points (px, py) for halo h.
    Returns (inside mask, values (nc, N)) - values are 0 outside."""
    m = h.model
    px = np.asarray(px, dtype=F64)
    py = np.asarray(py, dtype=F64)
    out = np.zeros((nc, px.size), dtype=F64)
    if not m:
        return np.zeros(px.size, dtype=bool), out
    R = h.rOut
    dx, dy = px - h.x, py - h.y
    r2 = dx * dx + dy * dy
    inside = r2 < R * R
    r = np.sqrt(r2)
    # Under the star the profile is flat (see build_halo_model), so the halo is removed
    # there as an even layer and the star keeps its own shape. Only the outer edge fades.
    tw = (R - r) / max(2, h.feather * 0.4 * R)
    tw = np.clip(tw, 0, 1)
    w = tw * tw * (3 - 2 * tw)
    inside &= w > 0
    if not inside.any():
        return inside, out
    idx = np.nonzero(inside)[0]
    r, w, dx, dy = r[idx], w[idx], dx[idx], dy[idx]
    nb = m.nb
    t = np.maximum(r / m.bw - 0.5, 0)
    b0 = np.floor(t).astype(np.int64)
    fb = t - b0
    b1 = b0 + 1
    over = b1 >= nb
    b1 = np.where(over, nb - 1, b1)
    b0 = np.where(over & (b0 > b1), b1, b0)
    NR = m.NR
    ang = np.arctan2(dy, dx)
    ang = np.where(ang < 0, ang + TWO_PI, ang)
    ta = ang / TWO_PI * HR_NA - 0.5
    ta = np.where(ta < 0, ta + HR_NA, ta)
    a0 = np.floor(ta)
    fa = ta - a0
    a0 = a0.astype(np.int64) % HR_NA
    a1 = (a0 + 1) % HR_NA
    tr = np.clip((r - m.rc0) / m.crw - 0.5, 0, NR - 1)
    q0 = np.floor(tr).astype(np.int64)
    fq = tr - q0
    q1 = np.minimum(NR - 1, q0 + 1)
    vals = np.empty((nc, idx.size), dtype=F64)
    for c in range(nc):
        Mc, Kc = m.M[c], m.K[c]
        v = Mc[b0] * (1 - fb) + Mc[b1] * fb
        k = (Kc[a0 * NR + q0] * (1 - fq) + Kc[a0 * NR + q1] * fq) * (1 - fa) + \
            (Kc[a1 * NR + q0] * (1 - fq) + Kc[a1 * NR + q1] * fq) * fa
        vals[c] = v * k
    gain = h.amount * w
    if chromaOnly and nc > 1:
        vals = vals - vals.min(axis=0)
    vals = vals * gain
    out[:, idx] = vals
    return inside, out


# ============================================================================
# Halo detection
# ============================================================================

def derivative_rows(A):
    A = np.asarray(A, dtype=F64)
    D = np.zeros_like(A)
    if A.shape[-1] >= 3:
        D[..., 1:-1] = 0.5 * (A[..., 2:] - A[..., :-2])
    return r32(D)


def detrended_rows(D):
    """Derivative with its smooth trend removed: D minus a running median whose window
    grows with radius (~30%). A star wing's slope is smooth and monotonic, so it cancels;
    a halo edge is a narrow notch and survives, even on steep wings close to the star."""
    D = np.asarray(D, dtype=F64)
    squeeze = (D.ndim == 1)
    if squeeze:
        D = D[None, :]
    n = D.shape[1]
    los = np.empty(n, dtype=np.int64)
    his = np.empty(n, dtype=np.int64)
    for i in range(n):
        k = max(3, jsround(0.3 * i))
        los[i], his[i] = max(0, i - k), min(n - 1, i + k)
    lens = his - los + 1
    med = np.empty_like(D)
    # Same medians as one window at a time, but all windows of equal length in one call
    for L in np.unique(lens):
        idx = np.nonzero(lens == L)[0]
        cols = los[idx][:, None] + np.arange(L)[None, :]
        med[:, idx] = np.median(D[:, cols], axis=2)
    out = r32(D - med)
    return out[0] if squeeze else out


def deriv_noise_from_profile(P, lo, hi):
    """Noise of a profile's derivative, from the profile's own bin-to-bin scatter (second
    differences). Unlike the spread of a detrended signal, this never collapses to zero."""
    P = np.asarray(P, dtype=F64)
    i0, i1 = max(1, lo), min(P.size - 2, hi)
    if i1 < i0:
        return 1e-7
    i = np.arange(i0, i1 + 1)
    a = np.abs(P[i] - 0.5 * (P[i - 1] + P[i + 1]))
    a = a[~np.isnan(a)]
    if a.size < 5:
        return 1e-7
    # Floor: medians of quantized data (8/16-bit) are often exactly equal, which would
    # make the measured scatter collapse to zero and every wiggle look significant.
    sigmaP = max(1.4826 * median_of(a) / math.sqrt(1.5), HR_QUANT)
    return sigmaP / math.sqrt(2)


class Edge(object):
    __slots__ = ("b", "bi", "prom", "score")

    def __init__(self, b, bi, prom, score):
        self.b, self.bi, self.prom, self.score = b, bi, prom, score


def find_edge(D, lo, hi, noise):
    """Strongest falling edge (local minimum of the derivative) within [lo, hi].
    D is a Python list. Prominence = depth relative to the derivative on both sides."""
    n = len(D)
    best = None
    for b in range(max(1, lo), min(hi, n - 2) + 1):
        db = D[b]
        if not (db < 0 and db <= D[b - 1] and db <= D[b + 1]):
            continue
        w = max(3, jsround(0.2 * b))
        lm = max(D[max(0, b - w):b])
        rm = max(D[b + 1:min(n - 1, b + w) + 1])
        prom = min(lm, rm) - db
        if not (prom > 0):
            continue
        score = prom / noise
        if best is None or score > best.score:
            den = D[b - 1] - 2 * db + D[b + 1]
            off = clamp(0.5 * (D[b - 1] - D[b + 1]) / den, -0.5, 0.5) if den > 0 else 0
            best = Edge(b + off, b, prom, score)
    return best


def find_edge_candidates(D, lo, hi, noise):
    """All falling-edge candidates (local minima of the derivative), best first."""
    n = len(D)
    out = []
    for b in range(max(1, lo), min(hi, n - 2) + 1):
        db = D[b]
        if not (db < 0 and db <= D[b - 1] and db <= D[b + 1]):
            continue
        e = find_edge(D, b, b, noise)
        if e and e.score >= 3:
            out.append(e)
    out.sort(key=lambda e: -e.score)
    return out[:6]


def robust_circle_fit(xs, ys, ws, bw):
    """Iteratively reweighted circle fit; drops outliers beyond 2.5 sigma (min 1.5 bins)."""
    if len(xs) < 8:
        return None
    keep = [True] * len(xs)
    fit = None
    for _ in range(4):
        fx = [xs[i] for i in range(len(xs)) if keep[i]]
        fy = [ys[i] for i in range(len(xs)) if keep[i]]
        fw = [ws[i] for i in range(len(xs)) if keep[i]]
        if len(fx) < 6:
            break
        f = fit_circle(fx, fy, fw)
        if not f:
            break
        fit = f
        res = []
        for i in range(len(xs)):
            dx, dy = xs[i] - f[0], ys[i] - f[1]
            res.append(abs(math.sqrt(dx * dx + dy * dy) - f[2]))
        thr = max(2.5 * 1.4826 * median_of(res), 1.5 * bw)
        keep = [r <= thr for r in res]
    if not fit:
        return None
    return (fit, keep, sum(1 for k in keep if k))


class Profiles(object):
    pass


def collect_profiles(img, nc, sx, sy, rMax):
    """Radial luminance profiles around (sx,sy) up to rMax*1.3, in 24/36 sectors."""
    H, W = img.shape[1], img.shape[2]
    # Small scales: fewer directions and bins wide enough that every (direction, bin)
    # cell near the halo still holds several pixels for a meaningful median.
    NS = 24 if rMax < 60 else 36
    sw = TWO_PI / NS
    Rlim = rMax * 1.3
    # ~5 pixels per (direction, bin) cell already at 30% of the scale's radius
    bw = max(0.5, rMax / 150, 2.65 * NS / rMax)
    nb = int(math.ceil(Rlim / bw)) + 1
    x0, x1 = max(0, int(math.floor(sx - Rlim))), min(W, int(math.ceil(sx + Rlim)))
    y0, y1 = max(0, int(math.floor(sy - Rlim))), min(H, int(math.ceil(sy + Rlim)))
    if x1 - x0 < 6 or y1 - y0 < 6:
        return None
    stride = max(1, int(math.ceil(math.sqrt((x1 - x0) * (y1 - y0) / 1.0e6))))
    Rlim2 = Rlim * Rlim
    ys = np.arange(y0, y1, stride)
    dy = ys + 0.5 - sy
    rowok = ~(dy * dy > Rlim2)
    ys, dy = ys[rowok], dy[rowok]
    xs = np.arange(x0, x1, stride)
    dx = xs + 0.5 - sx
    DX = np.broadcast_to(dx[None, :], (ys.size, xs.size))
    DY = np.broadcast_to(dy[:, None], (ys.size, xs.size))
    r2 = DX * DX + DY * DY
    ok = ~(r2 > Rlim2)
    ang = np.arctan2(DY[ok], DX[ok])
    ang = np.where(ang < 0, ang + TWO_PI, ang)
    sec = np.minimum(NS - 1, np.floor(ang / sw)).astype(np.int64)
    b = np.minimum(nb - 1, np.floor(np.sqrt(r2[ok]) / bw)).astype(np.int64)
    L = _lum(img, nc, ys, xs)[ok]
    P = bin_medians(sec * nb + b, L, NS * nb, 3).reshape(NS, nb)
    # Global profile: median over directions where at least half of them have data.
    # Inner bins without enough pixels stay excluded from the search (firstValid), so
    # filled-in values can never form an artificial edge.
    cnt = (~np.isnan(P)).sum(axis=0)
    G = np.full(nb, np.nan, dtype=F64)
    good = cnt >= NS / 2
    if good.any():
        with np.errstate(all="ignore"):
            G[good] = np.nanmedian(P[:, good], axis=0)
    G = r32(G)
    if not good.any():
        return None
    firstValid = int(np.argmax(good))
    fill_nan(G)
    # Smoothed sector profiles and their derivatives
    Psec = r32(np.where(np.isnan(P), G[None, :], P))
    Psec = smooth_rows(Psec, 1)
    Dsec = detrended_rows(derivative_rows(Psec))
    pr = Profiles()
    pr.bw, pr.nb, pr.NS, pr.sw, pr.Rlim, pr.rMax = bw, nb, NS, sw, Rlim, rMax
    pr.G = G
    pr.Psec = Psec
    pr.PsecL = [row.tolist() for row in Psec]
    pr.DsecL = [row.tolist() for row in Dsec]
    pr.Nsec = [deriv_noise_from_profile(Psec[s_], 2, nb - 2) for s_ in range(NS)]
    pr.LsecL = None
    pr.LNsec = None
    pr.bg = 0.0
    pr.zcache = {}
    pr.rValid = (firstValid + 4) * bw
    return pr


def log_slope_edges_rows(Ps, bg, noiseP, refLevel):
    """Detrended log-log slope of each profile: d ln(I - bg) / d ln r, minus its running
    median. Star wings are close to power laws, so their log-log slope is nearly constant and
    cancels; a halo edge on top of a steep wing becomes a sharp notch. Only bins well above
    the background are used (elsewhere the result is 0). Returns (out rows, noise per row)."""
    Ps = np.asarray(Ps, dtype=F64)
    ns, n = Ps.shape
    # Only where the star wing clearly dominates: far out, background structure and
    # background uncertainty make log-log slopes meaningless (the linear signal covers it).
    floor = bg + np.maximum(5 * np.asarray(noiseP, dtype=F64), 0.1 * np.asarray(refLevel, dtype=F64))
    with np.errstate(all="ignore"):
        L = r32(np.where(Ps > floor[:, None], np.log(Ps - bg), np.nan))
        S = np.full((ns, n), np.nan, dtype=F64)
        if n >= 3:
            i = np.arange(1, n - 1)
            den = np.log((i + 1.5) / (i - 0.5))
            okS = ~np.isnan(L[:, :-2]) & ~np.isnan(L[:, 2:])
            S[:, 1:-1] = np.where(okS, (L[:, 2:] - L[:, :-2]) / den[None, :], np.nan)
        S = r32(S)
        valid = ~np.isnan(S)
        anyv = valid.any(axis=1)
        sdm = np.zeros((ns, max(0, n - 2)), dtype=bool)
        sdv = np.zeros((ns, max(0, n - 2)), dtype=F64)
        if n >= 3:
            sdm = ~np.isnan(L[:, :-2]) & ~np.isnan(L[:, 1:-1]) & ~np.isnan(L[:, 2:])
            sdv = np.abs(L[:, 1:-1] - 0.5 * (L[:, :-2] + L[:, 2:]))
    for r in range(ns):
        fill_nan(S[r])
    out = detrended_rows(S)
    out[~valid] = 0
    noise = []
    for r in range(ns):
        if not anyv[r]:
            out[r] = 0
            noise.append(1e9)
            continue
        sd = sdv[r][sdm[r]]
        # Noise of the slope signal from the log profile's scatter (in slope units)
        sigmaL = 1.4826 * median_of(sd) / math.sqrt(1.5) if sd.size >= 4 else 1e9
        noise.append(max(sigmaL * math.sqrt(2) / math.log(3), 1e-7))
    return out, noise


def step_significance(Ps, bf, noiseD, R, bw, bg):
    """Is there a real brightness STEP at bin position bf in this profile (a list)? One
    joint fit across both sides: a smooth quadratic trend plus a step term that only applies
    inside the edge. Returns the step in noise units (<= 0: no step)."""
    nb = len(Ps)
    d = max(2, jsround(0.05 * R / bw))
    Lw = max(4, jsround(0.25 * R / bw))
    b = jsround(bf)
    iLo, iHi, oLo, oHi = b - d - Lw, b - d, b + d, b + d + Lw
    if iLo < 1:
        iLo = 1
    if oHi > nb - 1:
        oHi = nb - 1
    if iHi - iLo < 2 or oHi - oLo < 2:
        return 0
    sigmaP = 1.414 * noiseD
    logMode = True
    for i in range(iLo, oHi + 1):
        if not (Ps[i] > bg + 6 * sigmaP):
            logMode = False
            break
    xe = math.log((bf + 0.5) * bw) if logMode else (bf + 0.5) * bw
    scale = 1 if logMode else max((oHi - iLo) * bw, 1)
    A = [[0.0] * 4 for _ in range(4)]
    rhs = [0.0] * 4
    rows = []
    for i in range(iLo, oHi + 1):
        if iHi < i < oLo:
            continue
        r = (i + 0.5) * bw
        x = ((math.log(r) if logMode else r) - xe) / scale
        y = math.log(Ps[i] - bg) if logMode else Ps[i]
        w = (Ps[i] - bg) * (Ps[i] - bg) if logMode else 1   # equalize errors in log space
        v = (1, x, x * x, 1 if i <= iHi else 0)
        rows.append((v, y, w))
        for p in range(4):
            for q in range(4):
                A[p][q] += w * v[p] * v[q]
            rhs[p] += w * v[p] * y
    if len(rows) < 7:
        return 0
    sol = solve_n(A, rhs)
    if not sol:
        return 0
    e3 = solve_n(A, [0, 0, 0, 1])            # (A^-1)[3][3] = variance factor of the step
    if not e3 or not (e3[3] > 0):
        return 0
    ss = 0.0
    for (v, y, w) in rows:
        pred = 0.0
        for k in range(4):
            pred += sol[k] * v[k]
        ss += w * (y - pred) * (y - pred)
    dof = max(1, len(rows) - 4)
    s2 = max(ss / dof, sigmaP * sigmaP)       # never trust less than the profile noise
    return sol[3] / math.sqrt(s2 * e3[3])


def sector_consensus_candidates(pr, lo, hi):
    """Candidate radii where edges from many different directions cluster (tolerance grows
    with radius to allow for an offset center)."""
    NS, bw, nb = pr.NS, pr.bw, pr.nb
    bLo, bHi = max(1, int(math.floor(lo / bw))), min(nb - 2, int(math.ceil(hi / bw)))
    pr_r, pr_s = [], []
    for s_ in range(NS):
        cs = find_edge_candidates(pr.DsecL[s_], bLo, bHi, pr.Nsec[s_])
        if pr.LsecL is not None:
            cs = cs + find_edge_candidates(pr.LsecL[s_], bLo, bHi, pr.LNsec[s_])
        for e in cs:
            pr_r.append((e.b + 0.5) * bw)
            pr_s.append(s_)
    out = []
    if pr_r:
        ra = np.array(pr_r, dtype=F64)
        sa = np.array(pr_s, dtype=np.int64)
        for i in range(len(pr_r)):
            tol = 0.25 * pr_r[i] + bw
            idx = np.nonzero(np.abs(ra - pr_r[i]) <= tol)[0]
            _, first = np.unique(sa[idx], return_index=True)
            sel = np.sort(idx[first])
            n = sel.size
            if n >= 0.3 * NS:
                out.append((seqsum(ra[sel]) / n, n))
    out.sort(key=lambda o: -o[1])
    res = []
    for (r, n) in out:
        if len(res) >= 4:
            break
        dup = False
        for e in res:
            if abs((e.b + 0.5) * bw - r) < 0.15 * r:
                dup = True
        if not dup:
            res.append(Edge(r / bw - 0.5, -1, 0.0, n))
    return res


def add_log_edges(pr, bg):
    pr.bg = bg
    pr.zcache = {}
    bRef = min(pr.nb - 1, max(0, jsround(pr.rValid / pr.bw)))
    noiseP = [1.414 * v for v in pr.Nsec]
    ref = [max(0, pr.Psec[s_][bRef] - bg) for s_ in range(pr.NS)]
    Ls, noise = log_slope_edges_rows(pr.Psec, bg, noiseP, ref)
    pr.LsecL = [row.tolist() for row in Ls]
    pr.LNsec = noise


def sector_edge(pr, s_, lo, hi):
    """Best edge in one direction from either signal (linear or log-log)."""
    e1 = find_edge(pr.DsecL[s_], lo, hi, pr.Nsec[s_])
    e2 = find_edge(pr.LsecL[s_], lo, hi, pr.LNsec[s_]) if pr.LsecL is not None else None
    if e1 and e2:
        return e2 if e2.score > e1.score else e1
    return e1 or e2


class Ring(object):
    pass


def validate_ring(pr, sx, sy, R0, rMin):
    """Validate one candidate edge radius R0 at one scale: per-direction edges with a
    significant step, robust circle fit, low scatter. Returns a ring or None."""
    NS, bw, nb = pr.NS, pr.bw, pr.nb
    lo = max(1, int(math.floor(max(rMin, pr.rValid, 0.75 * R0) / bw)))
    hi = min(nb - 2, int(math.ceil(1.3 * R0 / bw)))
    if hi - lo < 3:
        return None
    xs, ys, ws, sig = [], [], [], []
    for s_ in range(NS):
        e = sector_edge(pr, s_, lo, hi)
        if not e or e.score < 2.5:
            continue
        re = (e.b + 0.5) * bw
        zk = (s_, e.b)      # a pure function of the sector and the edge: computed once per scale
        z = pr.zcache.get(zk)
        if z is None:
            z = step_significance(pr.PsecL[s_], e.b, pr.Nsec[s_], re, bw, pr.bg)
            pr.zcache[zk] = z
        if z < 3:
            continue
        a = (s_ + 0.5) * pr.sw
        xs.append(re * math.cos(a))
        ys.append(re * math.sin(a))
        ws.append(e.prom)
        sig.append(z)
    rc = robust_circle_fit(xs, ys, ws, bw)
    if not rc:
        return None
    f, keep, nIn = rc
    off = math.sqrt(f[0] * f[0] + f[1] * f[1])
    if off > 0.35 * f[2] or f[2] < rMin or f[2] > pr.rMax * 1.3:
        return None
    # Scatter of the accepted edge points around the circle (after shape freedom of ~4%)
    res, pts, zs = [], [], []
    for i in range(len(xs)):
        if not keep[i]:
            continue
        qx, qy = xs[i] - f[0], ys[i] - f[1]
        qr = math.sqrt(qx * qx + qy * qy)
        qa = math.atan2(qy, qx)
        if qa < 0:
            qa += TWO_PI
        res.append(abs(qr - f[2]))
        pts.append((qa, qr))
        zs.append(sig[i])
    scatter = median_of(res)
    # Real halos are often not round (aperture-shaped, lopsided); only reject clearly
    # scattered point sets.
    if scatter > max(2 * bw, 0.1 * f[2]):
        return None
    quality = nIn / NS
    if quality < 0.5:
        return None
    # Coverage: the accepted directions must surround the center, not cluster on one side
    cs, sn = 0.0, 0.0
    for (a, _r) in pts:
        cs += math.cos(a)
        sn += math.sin(a)
    if math.sqrt(cs * cs + sn * sn) / len(pts) > 0.5:
        return None
    strength = median_of(zs)
    if strength < 4:
        return None
    # Accepted. For the shape, re-measure the edge in every direction close to the fitted
    # circle (no step test: the ring is already known to be real), then fit again.
    xs2, ys2, ws2 = [], [], []
    for s_ in range(NS):
        a2 = (s_ + 0.5) * pr.sw
        ca, sa = math.cos(a2), math.sin(a2)
        bdot = f[0] * ca + f[1] * sa
        disc = bdot * bdot - (f[0] * f[0] + f[1] * f[1] - f[2] * f[2])
        if disc <= 0:
            continue
        rExp = bdot + math.sqrt(disc)
        tol = max(2 * bw, 0.08 * f[2])
        e2 = find_edge(pr.DsecL[s_], max(1, int(math.floor((rExp - tol) / bw))),
                       min(nb - 2, int(math.ceil((rExp + tol) / bw))), pr.Nsec[s_])
        if not e2 or e2.score < 2.5:
            continue
        re2 = (e2.b + 0.5) * bw
        xs2.append(re2 * ca)
        ys2.append(re2 * sa)
        ws2.append(e2.prom)
    rc2 = robust_circle_fit(xs2, ys2, ws2, bw)
    if rc2 and rc2[2] > nIn:
        f2 = rc2[0]
        off2 = math.sqrt(f2[0] * f2[0] + f2[1] * f2[1])
        if off2 <= 0.35 * f2[2] and abs(f2[2] - f[2]) < 0.15 * f[2]:
            f, off = f2, off2
            pts = []
            for i in range(len(xs2)):
                if not rc2[1][i]:
                    continue
                px2, py2 = xs2[i] - f[0], ys2[i] - f[1]
                pa = math.atan2(py2, px2)
                if pa < 0:
                    pa += TWO_PI
                pts.append((pa, math.sqrt(px2 * px2 + py2 * py2)))
    ring = Ring()
    ring.x, ring.y, ring.R, ring.offset = sx + f[0], sy + f[1], f[2], off
    ring.quality, ring.inliers, ring.total, ring.strength, ring.pts = quality, nIn, NS, strength, pts
    return ring


def detect_halo(img, nc, sx, sy, rMin, rMax):
    """Detect halos around the star at (sx,sy). The search runs at several scales, and every
    candidate ring must show a real brightness step in most directions, lying on a circle.
    Returns (rings sorted small to large, suggested index) or None."""
    rMax = max(rMax, 12)
    scales = []
    r = max(32, 3 * rMin)
    while r < rMax:
        scales.append(r)
        r *= 2
    scales.append(rMax)
    # Far background, measured once at the largest scale (small scales don't reach it)
    prL = collect_profiles(img, nc, sx, sy, rMax)
    if not prL:
        return None
    blo = int(math.floor(rMax * 1.15 / prL.bw))
    tmpL = prL.G[blo:prL.nb] if blo < prL.nb else np.zeros(0)
    bg = median_of(tmpL) if tmpL.size > 0 else float(prL.G[prL.nb - 1])

    rings = []
    rMinEff, prevCover, rHalf = rMin, 0, 1
    for si in range(len(scales)):
        pr = prL if si == len(scales) - 1 else collect_profiles(img, nc, sx, sy, scales[si])
        if not pr:
            continue
        add_log_edges(pr, bg)
        bw, nb, G = pr.bw, pr.nb, pr.G
        if si == 0:
            # Star core size from the widest profile: on stretched images the star's glow
            # can be larger than the smallest scale, and its edge must not count as a halo.
            GL = prL.G
            peak = max(G[0], GL[0])
            rHalf = bw
            for b in range(prL.nb):
                if GL[b] - bg < 0.5 * (peak - bg):
                    rHalf = max(bw, (b + 0.5) * prL.bw)
                    break
            rMinEff = max(rMin, 1.6 * rHalf)
        # Each scale searches up to 80% of its range (room outside the edge for the step
        # test); consecutive scales overlap.
        hi = pr.rMax if si == len(scales) - 1 else 0.8 * pr.rMax
        lo = max(rMinEff, 3 * bw, pr.rValid, 0.7 * prevCover)
        prevCover = hi
        if lo >= hi:
            continue
        Gs = smooth_rows(G, 1)
        D = detrended_rows(derivative_rows(Gs)).tolist()
        cands = find_edge_candidates(D, int(math.floor(lo / bw)), min(nb - 2, int(math.ceil(hi / bw))),
                                     deriv_noise_from_profile(Gs, int(math.floor(lo / bw)), nb - 2))
        # Plus radii that many single directions agree on. Needed for small halos that are
        # offset from the star: their edge is smeared out in the all-direction average.
        cands = cands + sector_consensus_candidates(pr, lo, hi)
        GlL = G.tolist()
        for cand in cands:
            ring = validate_ring(pr, sx, sy, (cand.b + 0.5) * bw, rMinEff)
            if not ring:
                continue
            ring.bw = bw
            # Suggested radii from this scale's profile
            bE = jsround(ring.R / bw - 0.5)
            bEnd = bE
            dmin = D[clamp(bE, 0, nb - 1)]
            while bEnd < nb - 2 and D[bEnd] < 0.25 * dmin:
                bEnd += 1
            halfW = max(bw, (bEnd - bE) * bw)
            ring.rOut = max(ring.R * 1.06, ring.R + min(2 * halfW, 0.3 * ring.R)) + bw
            tmp = []
            b = int(math.floor(0.45 * ring.R / bw))
            while b <= int(math.floor(0.8 * ring.R / bw)) and b < nb:
                tmp.append(GlL[b])
                b += 1
            # Star radius: where the profile falls to twice the halo's own level, i.e. the
            # star's light has dropped to about the halo's brightness.
            plat = median_of(tmp) if tmp else bg
            exc = plat - bg
            rIn = 0.2 * ring.R
            if exc > 0:
                for b in range(nb):
                    if GlL[b] - bg < 2 * exc:
                        rIn = (b + 0.5) * bw
                        break
            ring.rIn = clamp(rIn, max(2, 1.2 * rHalf), 0.5 * ring.R)
            rings.append(ring)
    if not rings:
        return None
    # Merge duplicates found at neighbouring scales (keep the better one)
    rings.sort(key=lambda q: q.R)
    merged = []
    for rg in rings:
        last = merged[-1] if merged else None
        if last and abs(rg.R - last.R) < 0.15 * last.R:
            if rg.quality * rg.strength > last.quality * last.strength:
                merged[-1] = rg
        else:
            merged.append(rg)
    # Suggest the smallest ring that is well supported (>= 60% of directions) and at least
    # half as convincing as the best one; otherwise take the most convincing ring.
    best, index = 0, 0
    for k, rg in enumerate(merged):
        cv = rg.quality * rg.strength
        if cv > best:
            best, index = cv, k
    for k, rg in enumerate(merged):
        if rg.quality >= 0.6 and rg.quality * rg.strength >= 0.5 * best:
            index = k
            break
    return merged, index


def _lum_block(img, nc, x0, y0, x1, y1):
    """Luminance accumulated like the JS version: L += value/nc into a Float32Array."""
    L = np.zeros((y1 - y0, x1 - x0), dtype=F64)
    for c in range(nc):
        L = r32(L + img[c, y0:y1, x0:x1].astype(F64) / nc)
    return L


def _box9(L):
    """3x3 sums of the interior, summed in the same order as the JS version."""
    a = L
    return (a[:-2, :-2] + a[:-2, 1:-1] + a[:-2, 2:] + a[1:-1, :-2] + a[1:-1, 1:-1] +
            a[1:-1, 2:] + a[2:, :-2] + a[2:, 1:-1] + a[2:, 2:])


def refine_center(img, nc, x, y, rad):
    """Snap to the centroid of the brightest star near (x,y)."""
    H, W = img.shape[1], img.shape[2]
    rad = max(3, jsround(rad))
    x0, x1 = max(0, int(math.floor(x - rad))), min(W, int(math.ceil(x + rad)) + 1)
    y0, y1 = max(0, int(math.floor(y - rad))), min(H, int(math.ceil(y + rad)) + 1)
    w, hh = x1 - x0, y1 - y0
    if w < 3 or hh < 3:
        return (x, y)
    L = _lum_block(img, nc, x0, y0, x1, y1)
    S = _box9(L)
    ii = np.arange(1, w - 1)
    jj = np.arange(1, hh - 1)
    ddx = (x0 + ii + 0.5 - x)[None, :]
    ddy = (y0 + jj + 0.5 - y)[:, None]
    ok = ~(ddx * ddx + ddy * ddy > rad * rad) & (S > -1)
    if not ok.any():
        return (x, y)
    Sm = np.where(ok, S, -np.inf)
    k = int(np.argmax(Sm))
    by, bx = k // Sm.shape[1] + 1, k % Sm.shape[1] + 1
    best = float(Sm.ravel()[k])
    med = median_of(L)
    peak = best / 9
    if not (peak > med):
        return (x, y)
    thr = med + 0.5 * (peak - med)
    cr = max(2, rad * 0.6)
    I = np.arange(w)[None, :]
    J = np.arange(hh)[:, None]
    ex, ey = I - bx, J - by
    v = L - thr
    m = ~(ex * ex + ey * ey > cr * cr) & (v > 0)
    if not m.any():
        return (x0 + bx + 0.5, y0 + by + 0.5)
    vv = v[m]
    sw = seqsum(vv)
    if sw <= 0:
        return (x0 + bx + 0.5, y0 + by + 0.5)
    Ib = np.broadcast_to(I, m.shape)[m]
    Jb = np.broadcast_to(J, m.shape)[m]
    sxv = seqsum(vv * (Ib + 0.5))
    syv = seqsum(vv * (Jb + 0.5))
    return (x0 + sxv / sw, y0 + syv / sw)


def peak_luminance(img, nc, x, y, rad, exX, exY, exRad):
    """Brightest 3x3-averaged luminance within 'rad' of (x,y), ignoring a disk of radius
    exRad around (exX, exY)."""
    H, W = img.shape[1], img.shape[2]
    x0, x1 = max(1, int(math.floor(x - rad))), min(W - 1, int(math.ceil(x + rad)) + 1)
    y0, y1 = max(1, int(math.floor(y - rad))), min(H - 1, int(math.ceil(y + rad)) + 1)
    if x1 - x0 < 3 or y1 - y0 < 3:
        return 0.0
    L = _lum_block(img, nc, x0 - 1, y0 - 1, x1 + 1, y1 + 1)
    V = _box9(L) / 9
    hh, w = L.shape
    px = (x0 - 1 + np.arange(1, w - 1) + 0.5)[None, :]
    py = (y0 - 1 + np.arange(1, hh - 1) + 0.5)[:, None]
    dx, dy = px - x, py - y
    ok = ~(dx * dx + dy * dy > rad * rad)
    if exRad > 0:
        ex, ey = px - exX, py - exY
        ok &= ~(ex * ex + ey * ey < exRad * exRad)
    ok &= V > 0
    if not ok.any():
        return 0.0
    return float(V[ok].max())


def estimate_star_core(img, nc, sx, sy):
    """Half-maximum radius of the star."""
    H, W = img.shape[1], img.shape[2]
    rad = 60
    x0, x1 = max(0, int(math.floor(sx - rad))), min(W, int(math.ceil(sx + rad)))
    y0, y1 = max(0, int(math.floor(sy - rad))), min(H, int(math.ceil(sy + rad)))
    nb = 2 * rad
    P = np.full(nb, np.nan, dtype=F64)
    if x1 > x0 and y1 > y0:
        ys, xs = np.arange(y0, y1), np.arange(x0, x1)
        dx = (xs + 0.5 - sx)[None, :]
        dy = (ys + 0.5 - sy)[:, None]
        r = np.sqrt(dx * dx + dy * dy)
        ok = r < rad
        L = _lum(img, nc, ys, xs)
        P = bin_medians(np.floor(r[ok] / 0.5).astype(np.int64), L[ok], nb, 1)
    fill_nan(P)
    lo = P[nb - 1]
    half = lo + 0.5 * (P[0] - lo)
    for b in range(nb):
        if P[b] < half:
            return max(1, (b + 0.5) * 0.5)
    return rad / 2


def star_core_radius(img, nc, sx, sy):
    """Star core radius: twice the half-maximum radius (covers a saturated center and the
    round top of the profile, where the star is not yet a power law)."""
    return clamp(2 * estimate_star_core(img, nc, sx, sy), 2, 80)


class ViewData(object):
    pass


def fetch_view(img, nc, rx, ry, rw, rh, dw, dh):
    """Nearest-neighbour sampling of an image region into a display-sized buffer."""
    H, W = img.shape[1], img.shape[2]
    xs = np.floor(rx + (np.arange(dw) + 0.5) * rw / dw).astype(np.int64)
    xs[(xs < 0) | (xs >= W)] = -1
    ys = np.floor(ry + (np.arange(dh) + 0.5) * rh / dh).astype(np.int64)
    ys[(ys < 0) | (ys >= H)] = -1
    data = np.full((nc, dh, dw), np.nan, dtype=F32)
    vi = np.nonzero(xs >= 0)[0]
    vj = np.nonzero(ys >= 0)[0]
    if vi.size and vj.size:
        for c in range(nc):
            data[c][np.ix_(vj, vi)] = img[c][np.ix_(ys[vj], xs[vi])]
    v = ViewData()
    v.rx, v.ry, v.rw, v.rh, v.dw, v.dh, v.xs, v.ys, v.data = rx, ry, rw, rh, dw, dh, xs, ys, data
    return v


# ============================================================================
# Engine
# ============================================================================

class HaloEngine(object):
    def __init__(self):
        self.img = None        # (nc, H, W) float32, row 0 = top of the image as displayed
        self.W = 0
        self.H = 0
        self.nc = 1
        self.halos = []
        self.current = -1
        self.stfAuto = (0.0, 0.5)
        self.stfStrong = (0.0, 0.5)
        self.version = 0
        self.settings = None   # object with load_state() / save_state(str), or None

    def set_image(self, img, is_real=True, bits=32):
        global HR_QUANT
        img = np.ascontiguousarray(img, dtype=F32)
        if img.ndim == 2:
            img = img[None, :, :]
        same = (img.shape[2] == self.W and img.shape[1] == self.H)
        self.img = img
        self.nc = 3 if img.shape[0] >= 3 else 1
        if img.shape[0] > 3:
            self.img = img[:3]
        self.H, self.W = img.shape[1], img.shape[2]
        self.version += 1
        # Integer data (8/16 bit) has steps; medians of steps can look noise-free
        HR_QUANT = 1e-7 if is_real else 0.3 / (2 ** bits - 1)
        sc = min(1, 1024 / max(self.W, self.H))
        dw, dh = max(1, jsround(self.W * sc)), max(1, jsround(self.H * sc))
        v = fetch_view(self.img, self.nc, 0, 0, self.W, self.H, dw, dh)
        self.stfAuto = compute_stf(v.data, self.nc, -2.8, 0.25)
        self.stfStrong = compute_stf(v.data, self.nc, -1.5, 0.40)
        if same and self.halos:
            for h in self.halos:
                h.modelKey = ""
        else:
            self.halos = []
            self.current = -1
            self.load_settings()

    def current_halo(self):
        return self.halos[self.current] if 0 <= self.current < len(self.halos) else None

    def default_radius(self):
        return max(10, jsround(min(self.W, self.H) / 25))

    def add_halo(self, x, y, rOut):
        h = Halo(x, y, rOut)
        cur = self.current_halo()
        if cur:
            h.rIn = rOut * (cur.rIn / cur.rOut)
            h.amount = cur.amount
            h.feather = cur.feather
            h.chromaOnly = cur.chromaOnly
        self.halos.append(h)
        self.current = len(self.halos) - 1
        return h

    def detect(self, h, rMin, rMax):
        """Auto-detect the halo around halo h's star: size, star radius and the halo's own
        center (which may be off the star). Returns True if a halo was found."""
        img, nc = self.img, self.nc
        core = star_core_radius(img, nc, h.sx, h.sy)
        # 1. Halo with a clear edge: the circle fitted to the edge gives size and center
        d = detect_halo(img, nc, h.sx, h.sy, rMin, rMax)
        if d:
            g = d[0][d[1]]
            rIn = min(core, 0.4 * g.R)
            if self.plausible(h, g.x, g.y, g.rOut, rIn):
                h.x, h.y, h.rOut, h.rIn = g.x, g.y, g.rOut, rIn
                return True
        # 2. Diffuse glow without a sharp edge: center and size from the two-part fit
        h.rIn = core
        geo = find_halo_geometry(img, nc, h, h.sx, h.sy, 0, rMax)
        if geo:
            rIn2 = min(core, 0.4 * geo[2])
            if self.plausible(h, geo[0], geo[1], geo[2], rIn2):
                h.x, h.y, h.rOut, h.rIn = geo[0], geo[1], geo[2], rIn2
                return True
        return False

    def plausible(self, h, x, y, R, rIn):
        """Does a halo at (x, y) with radius R plausibly belong to halo h's star?"""
        img, nc = self.img, self.nc
        # not the same as an existing halo
        for o in self.halos:
            if o is h:
                continue
            dd = math.sqrt((o.x - x) * (o.x - x) + (o.y - y) * (o.y - y))
            big = max(o.rOut, R)
            if dd < 0.25 * big and abs(o.rOut - R) < 0.5 * big:
                return False
        # there must actually be halo light inside: median halo level clearly above the noise
        gs = collect_geometry_set(img, nc, h.sx, h.sy, R, max(2, rIn))
        if not gs:
            return False
        _, Hp, bw = fit_two_part(gs, x - h.sx, y - h.sy, R)
        lv = []
        for b in range(Hp.size):
            rb = (b + 0.5) * bw
            if max(0.4 * R, rIn) <= rb <= 0.85 * R:
                lv.append(Hp[b])
        if not lv or median_of(lv) < gs.sigma:
            return False
        # no clearly brighter star inside: then it would be that star's halo
        own = peak_luminance(img, nc, h.sx, h.sy, 3, -1, -1, 0)
        other = peak_luminance(img, nc, x, y, 0.6 * R, h.sx, h.sy, max(3, rIn) + 2)
        return not (other > 1.5 * own)

    def remove_current(self):
        if self.current < 0:
            return
        del self.halos[self.current]
        if self.current >= len(self.halos):
            self.current = len(self.halos) - 1

    def ensure_model(self, h):
        key = h.key_string()
        if h.modelKey != key:
            h.model = build_halo_model(self.img, self.nc, h)
            h.modelKey = key

    def ensure_models(self):
        for h in self.halos:
            self.ensure_model(h)

    def pending_models(self):
        return sum(1 for h in self.halos if h.modelKey != h.key_string())

    def apply_to(self, image):
        """Subtract all halos from image (nc, H, W) float32, in place."""
        nc = self.nc
        H, W = image.shape[1], image.shape[2]
        for h in self.halos:
            if not h.model or h.amount <= 0:
                continue
            R = h.rOut
            x0, x1 = max(0, int(math.floor(h.x - R))), min(W, int(math.ceil(h.x + R)))
            y0, y1 = max(0, int(math.floor(h.y - R))), min(H, int(math.ceil(h.y + R)))
            if x1 <= x0 or y1 <= y0:
                continue
            X, Y = np.meshgrid(np.arange(x0, x1) + 0.5, np.arange(y0, y1) + 0.5)
            inside, vals = eval_halo(h, X.ravel(), Y.ravel(), nc, h.chromaOnly)
            if not inside.any():
                continue
            idx = np.nonzero(inside)[0]
            for c in range(nc):
                buf = image[c, y0:y1, x0:x1].reshape(-1)
                b = buf[idx].astype(F64)
                buf[idx] = np.clip(b - vals[c, idx] * sat_factor(b), 0, 1).astype(F32)
                image[c, y0:y1, x0:x1] = buf.reshape(y1 - y0, x1 - x0)

    def result(self):
        """The corrected image (models always come from the unmodified image)."""
        self.ensure_models()
        out = self.img.copy()
        self.apply_to(out)
        return out

    def save_settings(self):
        if self.settings is None or self.img is None:
            return
        o = {"W": self.W, "H": self.H, "halos": [h.to_plain() for h in self.halos]}
        try:
            self.settings.save_state(json.dumps(o))
        except Exception:
            pass

    def load_settings(self):
        if self.settings is None:
            return
        try:
            st = self.settings.load_state()
            if not st:
                return
            o = json.loads(st)
            if o.get("W") == self.W and o.get("H") == self.H and o.get("halos"):
                for p in o["halos"]:
                    self.halos.append(halo_from_plain(p))
                self.current = 0
        except Exception:
            pass


# ============================================================================
# Siril connection
#
# Siril keeps images bottom-up (FITS convention: the first row is displayed at the
# bottom). The engine works top-down like the PixInsight version, so rows are flipped
# when reading and flipped back when writing.
# ============================================================================

class SirilBridge(object):
    def __init__(self, siril):
        self.siril = siril
        self.dtype = np.float32
        self.ndim = 3

    def log(self, text, color=None):
        try:
            if color is not None:
                self.siril.log(text, color)
            else:
                self.siril.log(text)
        except Exception:
            print(text)

    def to_work(self, raw):
        a = np.asarray(raw)
        self.dtype = a.dtype
        self.ndim = a.ndim
        if a.ndim == 2:
            a = a[None, :, :]
        a = a[:, ::-1, :]                       # bottom-up -> top-down
        if np.issubdtype(a.dtype, np.integer):
            return np.ascontiguousarray(a, dtype=np.float32) / np.float32(np.iinfo(a.dtype).max)
        return np.ascontiguousarray(a, dtype=np.float32)

    def from_work(self, work):
        a = work[:, ::-1, :]                    # top-down -> bottom-up
        if np.issubdtype(self.dtype, np.integer):
            mx = np.iinfo(self.dtype).max
            a = np.round(np.clip(a, 0, 1) * mx).astype(self.dtype)
        else:
            a = a.astype(np.float32)
        if self.ndim == 2:
            a = a[0]
        return np.ascontiguousarray(a)

    def load(self):
        raw = self.siril.get_image_pixeldata()
        if raw is None:
            raise RuntimeError("Siril returned no pixel data.")
        work = self.to_work(raw)
        is_int = np.issubdtype(self.dtype, np.integer)
        return work, (not is_int), (np.iinfo(self.dtype).bits if is_int else 32)

    def can_save_new(self):
        return hasattr(self.siril, "save_image_file")

    def new_image_path(self):
        base = None
        try:
            fn = self.siril.get_image_filename()
            if fn:
                base = os.path.splitext(fn)[0]
        except Exception:
            base = None
        if not base:
            try:
                base = os.path.join(self.siril.get_siril_wd(), "image")
            except Exception:
                base = os.path.join(os.getcwd(), "image")
        path = base + "_halo.fit"
        k = 2
        while os.path.exists(path):
            path = "%s_halo%d.fit" % (base, k)
            k += 1
        return path

    def apply(self, engine, new_image):
        """Apply the halos to the image in Siril. Returns a message."""
        s_ = self.siril
        n = len(engine.halos)
        self.log("%s %s: processing %d halo(s)..." % (HR_TITLE, HR_VERSION, n))
        t0 = time.time()
        try:
            s_.update_progress("Halo Swatter: removing halos", -1.0)
        except Exception:
            pass
        try:
            if new_image:
                out = self.from_work(engine.result())
                header = None
                try:
                    header = s_.get_image_fits_header()
                except Exception:
                    header = None
                path = self.new_image_path()
                s_.save_image_file(out, header=header or "", filename=path)
                msg = "Saved the result as %s (the loaded image is unchanged)." % path
                self.log("Halo Swatter: " + msg)
            else:
                with s_.image_lock():
                    fresh = self.to_work(s_.get_image_pixeldata())
                    if fresh.shape != engine.img.shape:
                        raise RuntimeError("The image in Siril has changed size; restart the script.")
                    if not np.array_equal(fresh, engine.img):
                        # The image was changed in Siril meanwhile: measure on what is there now
                        self.log("Halo Swatter: the image changed since the script started; "
                                 "halos are re-measured on the current image.")
                        engine.set_image(fresh, *self._real_bits())
                    out = self.from_work(engine.result())
                    s_.undo_save_state("Halo Swatter (%d halo%s)" % (n, "" if n == 1 else "s"))
                    s_.set_image_pixeldata(out)
                msg = "Done."
        finally:
            try:
                s_.reset_progress()
            except Exception:
                pass
        self.log("Done in %.2f s." % (time.time() - t0))
        return msg

    def _real_bits(self):
        is_int = np.issubdtype(self.dtype, np.integer)
        return (not is_int), (np.iinfo(self.dtype).bits if is_int else 32)


class QtSettingsStore(object):
    """Persists the halos (per image size) and the dialog options with QSettings."""

    def __init__(self):
        self.q = QSettings(HR_SETTINGS_ORG, HR_SETTINGS_APP)

    def load_state(self):
        v = self.q.value("state", "")
        return str(v) if v else ""

    def save_state(self, text):
        self.q.setValue("state", text)
        self.q.sync()

    def get(self, key, default):
        v = self.q.value(key, None)
        if v is None:
            return default
        if isinstance(default, bool):
            return str(v).lower() in ("true", "1")
        if isinstance(default, int):
            try:
                return int(v)
            except Exception:
                return default
        return v

    def set(self, key, value):
        self.q.setValue(key, value)


# ============================================================================
# Preview control
# ============================================================================

def _qcolor(argb):
    return QColor((argb >> 16) & 255, (argb >> 8) & 255, argb & 255, (argb >> 24) & 255)


class HaloPreview(QWidget):
    def __init__(self, engine, dialog):
        super().__init__(dialog)
        self.engine = engine
        self.dialog = dialog
        self.fitMode = True     # True = whole image fits the preview
        self.scale = 1.0        # display pixels per image pixel (when not fitting)
        self.vcx = 0.0          # image coordinates shown at the preview center
        self.vcy = 0.0
        self.displayMode = 0    # 0 = after, 1 = before, 2 = removed component
        self.stretchMode = 1    # 0 = linear, 1 = auto STF, 2 = strong STF
        self.showOverlay = True
        self.showOriginal = False
        self.bitmapOrig = None  # pre-rendered original, so toggling is instant
        self.bitmapOrigKey = ""
        self.spaceDown = False
        self.viewData = None
        self.viewKey = ""
        self.bitmap = None
        self.offX = 0
        self.offY = 0
        self.drag = None
        self.busy = False
        self.lastRenderMs = 0.0
        self.setMinimumSize(640, 480)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.setMouseTracking(True)
        self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)   # so Space works after clicking the preview
        self.setCursor(QCursor(Qt.CursorShape.CrossCursor))
        self.timer = QTimer(self)
        self.timer.setSingleShot(True)
        self.timer.setInterval(120)
        self.timer.timeout.connect(self.regenerate)

    def schedule_regenerate(self):
        self.timer.start()

    # ---- View state (zoom / pan) ------------------------------------------

    def fit_scale(self):
        eng = self.engine
        if eng.W < 1 or eng.H < 1 or self.width() < 1 or self.height() < 1:
            return 1.0
        return min(self.width() / eng.W, self.height() / eng.H)

    def view_state(self):
        eng = self.engine
        if self.fitMode:
            return (self.fit_scale(), eng.W / 2, eng.H / 2)
        return (self.scale, self.vcx, self.vcy)

    def set_view(self, scale, cx, cy):
        """Set zoom (display px per image px) and the image point shown at the preview center."""
        eng = self.engine
        fs = self.fit_scale()
        if scale <= fs * 1.001:
            self.fitMode = True
        else:
            self.fitMode = False
            self.scale = min(scale, HR_MAX_ZOOM)
            self.vcx = clamp(cx, 0, eng.W)
            self.vcy = clamp(cy, 0, eng.H)
        self.dialog.update_zoom_label()
        self.schedule_regenerate()

    def fit(self):
        self.fitMode = True
        self.dialog.update_zoom_label()
        self.schedule_regenerate()

    def zoom_at(self, x, y, factor):
        """Zoom by 'factor' keeping the image point under (x,y) fixed."""
        sc, cx, cy = self.view_state()
        w2, h2 = self.width() / 2, self.height() / 2
        px, py = cx + (x - w2) / sc, cy + (y - h2) / sc
        ns = clamp(sc * factor, self.fit_scale(), HR_MAX_ZOOM)
        self.set_view(ns, px - (x - w2) / ns, py - (y - h2) / ns)

    def zoom_to_halo(self, h):
        if not h:
            return
        L = 2.3 * h.rOut * (1 + HR_BG_RING)
        sc = min(min(self.width(), self.height()) / L, 8)
        sc = max(sc, self.fit_scale() * 1.01)   # always actually zoom in a bit
        self.set_view(sc, h.x, h.y)

    def zoom_one_to_one(self):
        _sc, cx, cy = self.view_state()
        h = self.engine.current_halo()
        if self.fitMode and h:
            self.set_view(1, h.x, h.y)
        else:
            self.set_view(1, cx, cy)

    def ensure_visible(self, h):
        """Recenter on a halo if it is outside the current zoomed view."""
        if not h or self.fitMode:
            return
        hw, hh = self.width() / 2 / self.scale, self.height() / 2 / self.scale
        if abs(h.x - self.vcx) > hw * 0.9 or abs(h.y - self.vcy) > hh * 0.9:
            self.set_view(self.scale, h.x, h.y)

    def zoom_text(self):
        sc = self.view_state()[0]
        if sc >= 0.995:
            t = (str(jsround(sc)) if abs(sc - round(sc)) < 0.05 else "%.1f" % sc) + ":1"
        else:
            inv = 1 / sc
            t = "1:" + (str(jsround(inv)) if abs(inv - round(inv)) < 0.05 else "%.1f" % inv)
        return "Fit (" + t + ")" if self.fitMode else t

    def compute_view_rect(self):
        cw, ch, eng = self.width(), self.height(), self.engine
        if not self.fitMode:
            rw, rh = cw / self.scale, ch / self.scale
            return (self.vcx - rw / 2, self.vcy - rh / 2, rw, rh, cw, ch)
        sc = self.fit_scale()
        return (0.0, 0.0, float(eng.W), float(eng.H),
                max(1, jsround(eng.W * sc)), max(1, jsround(eng.H * sc)))

    def img_per_disp(self):
        return self.viewData.rw / self.viewData.dw if self.viewData else 1.0

    def to_image(self, x, y):
        v = self.viewData
        return (v.rx + (x - self.offX) * v.rw / v.dw, v.ry + (y - self.offY) * v.rh / v.dh)

    def to_display(self, x, y):
        v = self.viewData
        return (self.offX + (x - v.rx) * v.dw / v.rw, self.offY + (y - v.ry) * v.dh / v.rh)

    def regenerate(self):
        eng = self.engine
        if eng.img is None or self.width() < 16 or self.height() < 16:
            return
        if self.busy or self.drag:
            self.schedule_regenerate()
            return
        self.busy = True
        t0 = time.perf_counter()
        try:
            rx, ry, rw, rh, dw, dh = self.compute_view_rect()
            key = "%d:%.2f,%.2f,%.2f,%.2f,%d,%d" % (eng.version, rx, ry, rw, rh, dw, dh)
            if key != self.viewKey:
                self.viewData = fetch_view(eng.img, eng.nc, rx, ry, rw, rh, dw, dh)
                self.viewKey = key
            self.offX = int(math.floor((self.width() - dw) / 2))
            self.offY = int(math.floor((self.height() - dh) / 2))
            pending = eng.pending_models()
            if pending > 0:
                self.dialog.set_status("Measuring halo profile" +
                                       ("s (%d)" % pending if pending > 1 else "") + "...")
                QApplication.processEvents()
            eng.ensure_models()
            self.bitmap = self.render_bitmap(self.displayMode)
            origKey = self.viewKey + "|" + str(self.stretchMode)
            if self.bitmapOrigKey != origKey or self.bitmapOrig is None:
                self.bitmapOrig = self.render_bitmap(1)   # the original only changes with the view
                self.bitmapOrigKey = origKey
            self.dialog.set_status(self.dialog.default_status())
        except Exception as e:   # keep the dialog alive
            self.dialog.set_status("Error: %s" % e)
            self.dialog.log("%s: %s" % (HR_TITLE, e))
        finally:
            self.busy = False
        self.lastRenderMs = (time.perf_counter() - t0) * 1000
        self.update()

    def render_image_values(self, mode):
        """Display values before the stretch: (nc, dh, dw) float, NaN outside the image.
        mode: 0 = after, 1 = original, 2 = removed component."""
        v, eng = self.viewData, self.engine
        nc, dw, dh = eng.nc, v.dw, v.dh
        if mode == 2:
            out = np.zeros((nc, dh, dw), dtype=F64)
        else:
            out = v.data.astype(F64)
        if mode != 1:
            sxs, sys_ = v.dw / v.rw, v.dh / v.rh
            # Speed: the halo model is smooth, so it is evaluated on a coarse grid (about one
            # point per image pixel when zoomed in, every 2nd screen pixel otherwise) and
            # interpolated in between.
            g = min(4, int(math.floor(sxs))) if sxs >= 2 else 2
            for h in eng.halos:
                if not h.model or h.amount <= 0:
                    continue
                chroma = h.chromaOnly and nc > 1
                # only the halo's own area on screen
                i0 = max(0, int(math.floor((h.x - h.rOut - v.rx) * sxs)) - 1)
                i1 = min(dw, int(math.ceil((h.x + h.rOut - v.rx) * sxs)) + 1)
                j0 = max(0, int(math.floor((h.y - h.rOut - v.ry) * sys_)) - 1)
                j1 = min(dh, int(math.ceil((h.y + h.rOut - v.ry) * sys_)) + 1)
                if i1 <= i0 or j1 <= j0:
                    continue
                gw = int(math.ceil((i1 - i0 - 1) / g)) + 1
                gh = int(math.ceil((j1 - j0 - 1) / g)) + 1
                gpx = v.rx + (i0 + np.arange(gw) * g + 0.5) / sxs
                gpy = v.ry + (j0 + np.arange(gh) * g + 0.5) / sys_
                PX, PY = np.meshgrid(gpx, gpy)
                _inside, vals = eval_halo(h, PX.ravel(), PY.ravel(), nc, chroma)
                grid = r32(vals).reshape(nc, gh, gw)
                # valid display rows/columns (inside the image) form one contiguous range
                rows = np.nonzero(v.ys[j0:j1] >= 0)[0]
                cols = np.nonzero(v.xs[i0:i1] >= 0)[0]
                if rows.size == 0 or cols.size == 0:
                    continue
                ja, jb = j0 + rows[0], j0 + rows[-1] + 1
                ia, ib = i0 + cols[0], i0 + cols[-1] + 1
                ty = (np.arange(ja, jb) - j0) / g
                gy0 = np.minimum(gh - 1, np.floor(ty).astype(np.int64))
                gy1 = np.minimum(gh - 1, gy0 + 1)
                fy = (ty - gy0)[:, None]
                tx = (np.arange(ia, ib) - i0) / g
                gx0 = np.minimum(gw - 1, np.floor(tx).astype(np.int64))
                gx1 = np.minimum(gw - 1, gx0 + 1)
                fx = (tx - gx0)[None, :]
                for c in range(nc):
                    G = grid[c]
                    # (G00*(1-fx) + G01*fx)*(1-fy) + (G10*(1-fx) + G11*fx)*fy, the row terms shared
                    Gx = G[:, gx0] * (1 - fx) + G[:, gx1] * fx
                    corr = Gx[gy0] * (1 - fy) + Gx[gy1] * fy
                    blk = out[c, ja:jb, ia:ib]
                    if mode == 0:
                        blk = blk - corr * sat_factor(v.data[c, ja:jb, ia:ib].astype(F64))
                    else:
                        blk = blk + corr
                    out[c, ja:jb, ia:ib] = r32(blk)
        return out

    def render_display_values(self, mode):
        """Stretched display values (nc, dh, dw) in 0..1, as the JS version passes them to
        Image.render(). The stretch is evaluated per pixel. No lookup table: on linear data
        it is steepest just above the background, and a table's steps there turned the sky
        into a few levels per channel (black sky, colored speckles)."""
        eng = self.engine
        out = self.render_image_values(mode)
        if mode == 2:
            mx = np.nanmax(out) if out.size else 0.0
            m, c0, sc = 0.15, 0.0, (1 / float(mx) if mx > 0 else 1.0)
        else:
            stf = eng.stfAuto if self.stretchMode == 1 else (eng.stfStrong if self.stretchMode == 2 else None)
            if stf:
                c0, m = stf
                sc = 1 / (1 - c0)
            else:
                m, c0, sc = 0.5, 0.0, 1.0           # linear
        with np.errstate(invalid="ignore"):
            res = mtf_vec(m, (out - c0) * sc)
        res[np.isnan(out)] = 0                      # outside the image
        return res

    def render_bitmap(self, mode):
        """mode: 0 = after, 1 = original, 2 = removed component. Returns a QImage."""
        v = self.viewData
        if v is None:
            return None
        nc, dw, dh = self.engine.nc, v.dw, v.dh
        b8 = np.round(self.render_display_values(mode) * 255).astype(np.uint8)
        if nc == 3:
            arr = np.ascontiguousarray(np.transpose(b8, (1, 2, 0)))
            qimg = QImage(arr.tobytes(), dw, dh, 3 * dw, QImage.Format.Format_RGB888)
        else:
            arr = np.ascontiguousarray(b8[0])
            qimg = QImage(arr.tobytes(), dw, dh, dw, QImage.Format.Format_Grayscale8)
        return qimg.copy()

    # ---- Painting -------------------------------------------------------------

    def draw_overlay(self, g):
        eng, v = self.engine, self.viewData
        g.setRenderHint(QPainter.RenderHint.Antialiasing, True)
        g.setBrush(Qt.BrushStyle.NoBrush)
        sx = v.dw / v.rw

        def ellipse(cx, cy, r):
            g.drawEllipse(QPointF(cx, cy), r, r)

        for i, h in enumerate(eng.halos):
            cur = (i == eng.current)
            cx, cy = self.to_display(h.x, h.y)       # halo center
            kx, ky = self.to_display(h.sx, h.sy)     # star center
            col = _qcolor(0xffffc830 if cur else 0xff40c0ff)
            ro, ri, rb = h.rOut * sx, h.rIn * sx, h.rOut * (1 + HR_BG_RING) * sx
            g.setPen(QPen(col, 1.5 if cur else 1.0))
            ellipse(cx, cy, ro)
            if ri > 1:
                g.setPen(QPen(col, 1.0, Qt.PenStyle.DashLine))
                ellipse(kx, ky, ri)
            if cur:
                g.setPen(QPen(_qcolor(0xa0ffffff), 1.0, Qt.PenStyle.DotLine))
                ellipse(cx, cy, rb)
            g.setPen(QPen(col, 1.0))
            g.drawLine(QPointF(kx - 5, ky), QPointF(kx + 5, ky))
            g.drawLine(QPointF(kx, ky - 5), QPointF(kx, ky + 5))
            ox, oy = cx - kx, cy - ky
            if ox * ox + oy * oy > 4:
                # Offset halo: mark the halo center and link it to the star
                ellipse(cx, cy, 3)
                g.setPen(QPen(col, 1.0, Qt.PenStyle.DotLine))
                g.drawLine(QPointF(kx, ky), QPointF(cx, cy))
                g.setPen(QPen(col, 1.0))
            g.drawText(QPointF(cx + ro * 0.71 + 3, cy - ro * 0.71 - 3), str(i + 1))

    def paintEvent(self, _ev):
        g = QPainter(self)
        try:
            g.fillRect(self.rect(), _qcolor(0xff1c1c1c))
            bmp = self.bitmapOrig if (self.showOriginal and self.bitmapOrig is not None) else self.bitmap
            if bmp is not None:
                g.drawImage(int(self.offX), int(self.offY), bmp)
            if self.viewData is not None and (self.showOverlay or self.drag):
                self.draw_overlay(g)
            # Badge so it's always clear what is being shown
            label = "ORIGINAL" if self.showOriginal else ("REMOVED HALO" if self.displayMode == 2 else "PREVIEW")
            fm = QFontMetrics(self.font())
            fw, fh = fm.horizontalAdvance(label) + 14, fm.height() + 8
            g.setRenderHint(QPainter.RenderHint.Antialiasing, False)
            g.fillRect(QRectF(6, 6, fw, fh), _qcolor(0xc0a03020 if self.showOriginal else 0xc0206040))
            g.setPen(QPen(_qcolor(0xffffffff)))
            g.drawText(QPointF(13, 10 + fm.ascent()), label)
        finally:
            g.end()

    def set_show_original(self, on):
        if self.showOriginal == on:
            return
        self.showOriginal = on
        self.dialog.update_original_check()
        self.update()   # both bitmaps are pre-rendered: no recomputation needed

    def toggle_original(self):
        self.set_show_original(not self.showOriginal)

    # ---- Keyboard ---------------------------------------------------------------

    def keyPressEvent(self, ev):
        key = ev.key()
        if key in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace) and self.engine.current_halo() and not self.drag:
            self.engine.remove_current()
            self.dialog.halos_changed()
            return
        if key != Qt.Key.Key_Space:
            super().keyPressEvent(ev)
            return
        if ev.isAutoRepeat():
            return    # holding the key does not flicker
        if not self.spaceDown:
            self.toggle_original()
        self.spaceDown = True

    def keyReleaseEvent(self, ev):
        if ev.key() == Qt.Key.Key_Space and not ev.isAutoRepeat():
            self.spaceDown = False
        else:
            super().keyReleaseEvent(ev)

    def resizeEvent(self, ev):
        self.dialog.update_zoom_label()
        self.schedule_regenerate()
        super().resizeEvent(ev)

    # ---- Mouse --------------------------------------------------------------------

    def hit_test(self, x, y):
        """What is under the mouse? The selected halo first, then the others.
        'edge': outer circle (resize), 'hcenter': halo-center marker (move), 'center': star."""
        eng, v = self.engine, self.viewData
        if v is None:
            return None
        sc = v.dw / v.rw
        order = ([eng.current] if eng.current >= 0 else []) + \
                [i for i in range(len(eng.halos)) if i != eng.current]
        for k in order:
            h = eng.halos[k]
            cx, cy = self.to_display(h.x, h.y)
            dc = math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy))
            stx, sty = self.to_display(h.sx, h.sy)
            off = math.sqrt((cx - stx) * (cx - stx) + (cy - sty) * (cy - sty))
            if off > 6 and dc <= 6:
                return (k, "hcenter")
            if abs(dc - h.rOut * sc) <= 7:
                return (k, "edge")
            ds = math.sqrt((x - stx) * (x - stx) + (y - sty) * (y - sty))
            if ds <= max(8, min(h.rIn * sc, 0.5 * h.rOut * sc)):
                return (k, "center")
        return None

    def select_halo(self, index):
        eng = self.engine
        if index == eng.current:
            return
        eng.current = index
        self.dialog.clear_note()
        self.dialog.rebuild_list()
        self.dialog.update_controls()

    @staticmethod
    def _mods(ev):
        m = ev.modifiers()
        return (bool(m & Qt.KeyboardModifier.ShiftModifier),
                bool(m & Qt.KeyboardModifier.ControlModifier),
                bool(m & Qt.KeyboardModifier.AltModifier))

    def mousePressEvent(self, ev):
        eng = self.engine
        x, y = ev.position().x(), ev.position().y()
        button = ev.button()
        shift, ctrl, alt = self._mods(ev)
        if self.viewData is None or self.drag:
            return
        # Pan: middle button, or Alt + left button
        if button == Qt.MouseButton.MiddleButton or (button == Qt.MouseButton.LeftButton and alt):
            if self.fitMode:
                return   # whole image already visible
            self.drag = {"mode": "pan", "x0": x, "y0": y, "cx": self.vcx, "cy": self.vcy,
                         "offX": self.offX, "offY": self.offY}
            return
        if button not in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton):
            return
        # Right-drag or Ctrl+drag: resize the selected halo from anywhere
        h = eng.current_halo()
        if h and (button == Qt.MouseButton.RightButton or ctrl):
            self.drag = {"mode": "resize", "keepStar": True, "x0": x, "y0": y, "moved": True}
            self._drag_move(x, y)
            return
        if button != Qt.MouseButton.LeftButton:
            return
        # On an existing halo: select it (and resize it when dragging)
        hit = self.hit_test(x, y)
        if hit:
            self.select_halo(hit[0])
            # Halo-center marker, or Shift+drag from a halo's star: move the halo's center
            if hit[1] == "hcenter" or (hit[1] == "center" and shift):
                self.drag = {"mode": "movecenter", "x0": x, "y0": y, "moved": False}
            else:
                self.drag = {"mode": "resize" if hit[1] == "edge" else "select", "keepStar": True,
                             "x0": x, "y0": y, "moved": hit[1] == "edge"}
            self.update()
            return
        # Empty sky: always a NEW halo on the nearest star
        px, py = self.to_image(x, y)
        if px < 0 or py < 0 or px > eng.W or py > eng.H:
            return
        px, py = refine_center(eng.img, eng.nc, px, py, max(8, 30 * self.img_per_disp()))
        cur = eng.current_halo()
        h = eng.add_halo(px, py, cur.rOut if cur else eng.default_radius())
        h.rIn = min(star_core_radius(eng.img, eng.nc, px, py), 0.4 * h.rOut)
        self.dialog.clear_note()
        self.dialog.rebuild_list()
        self.drag = {"mode": "new", "keepStar": True, "x0": x, "y0": y, "moved": False}
        self.dialog.update_controls()
        self.update()

    def mouseMoveEvent(self, ev):
        x, y = ev.position().x(), ev.position().y()
        if self.viewData is None:
            return
        if not self.drag:
            # Hover feedback: what a click would do here
            hit = self.hit_test(x, y)
            what = "" if not hit else ("drag: resize halo " if hit[1] == "edge" else
                                       "drag: move center of halo " if hit[1] == "hcenter" else
                                       "click: select halo ")
            self.dialog.show_cursor_info(self.to_image(x, y), (what + str(hit[0] + 1)) if hit else "")
            return
        self._drag_move(x, y)

    def _drag_move(self, x, y):
        eng = self.engine
        d = self.drag
        if d["mode"] == "pan":
            # Shift the existing bitmap live; the view is re-rendered on release
            self.offX = d["offX"] + (x - d["x0"])
            self.offY = d["offY"] + (y - d["y0"])
            self.update()
            return
        h = eng.current_halo()
        if not h:
            return
        if not d["moved"]:
            ddx, ddy = x - d["x0"], y - d["y0"]
            if ddx * ddx + ddy * ddy < 16:
                return   # a plain click doesn't change the size
            d["moved"] = True
        if d["mode"] == "movecenter":
            # The star stays; only the halo's own center follows the mouse (max. 60% of the radius away)
            qx, qy = self.to_image(x, y)
            mx, my = qx - h.sx, qy - h.sy
            md = math.sqrt(mx * mx + my * my)
            lim = 0.6 * h.rOut
            if md > lim:
                mx *= lim / md
                my *= lim / md
            h.x, h.y = h.sx + mx, h.sy + my
            self.dialog.update_list_row(eng.current)
            self.update()
            return
        cx, cy = self.to_display(h.x, h.y)
        ex, ey = x - cx, y - cy
        r = max(3, math.sqrt(ex * ex + ey * ey) * self.img_per_disp())
        h.rOut = r
        if d.get("keepStar", True):
            h.rIn = min(h.rIn, 0.9 * r)     # resizing the halo keeps the star size
        else:
            h.rIn = d["ratio"] * r
        self.dialog.update_controls()
        self.dialog.update_list_row(eng.current)
        self.update()

    def mouseReleaseEvent(self, ev):
        if not self.drag:
            return
        x, y = ev.position().x(), ev.position().y()
        d = self.drag
        self.drag = None
        if d["mode"] == "pan":
            self.set_view(self.scale, d["cx"] - (x - d["x0"]) / self.scale, d["cy"] - (y - d["y0"]) / self.scale)
            return
        if d["mode"] == "new" and not d["moved"] and self.dialog.autoDetectCheck.isChecked():
            self.dialog.detect_current()   # plain click on a star: measure its halo
        elif d["moved"]:
            self.dialog.clear_note()
        self.dialog.halo_changed()
        self.regenerate()

    def wheelEvent(self, ev):
        if self.drag:
            return
        ad = ev.angleDelta()
        delta = ad.y() if ad.y() != 0 else ad.x()   # some platforms turn Shift+wheel into horizontal scroll
        if delta == 0:
            return
        x, y = ev.position().x(), ev.position().y()
        shift, ctrl, _alt = self._mods(ev)
        # Plain wheel: zoom around the cursor
        if not shift and not ctrl:
            self.zoom_at(x, y, math.pow(1.25, delta / 120))
            return
        # Shift + wheel: outer radius, Ctrl + wheel: inner radius
        h = self.engine.current_halo()
        if not h:
            return
        f = math.pow(1.05, delta / 120)
        if ctrl:
            h.rIn = clamp(h.rIn * f, 0, 0.95 * h.rOut)
        else:
            ratio = h.rIn / h.rOut
            h.rOut = max(3, h.rOut * f)
            h.rIn = ratio * h.rOut
        self.dialog.halo_changed()


# ============================================================================
# Dialog
# ============================================================================

class NumericControl(QWidget):
    """Label + edit field + slider, like PixInsight's NumericControl."""

    def __init__(self, parent, label, lo, hi, prec, steps, tip, label_width, on_change):
        super().__init__(parent)
        self.lo, self.hi, self.steps, self.prec = float(lo), float(hi), int(steps), prec
        self.on_change = on_change
        self._value = float(lo)
        lay = QHBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(4)
        self.label = QLabel(label, self)
        self.label.setFixedWidth(label_width)
        self.edit = QDoubleSpinBox(self)
        self.edit.setDecimals(prec)
        self.edit.setRange(self.lo, self.hi)
        self.edit.setSingleStep(10 ** -prec if prec > 0 else 1)
        self.edit.setKeyboardTracking(False)
        self.edit.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons)
        self.edit.setFixedWidth(QFontMetrics(self.font()).horizontalAdvance("000000.0") + 14)
        self.slider = QSlider(Qt.Orientation.Horizontal, self)
        self.slider.setRange(0, self.steps)
        self.slider.setMinimumWidth(160)
        lay.addWidget(self.label)
        lay.addWidget(self.edit)
        lay.addWidget(self.slider, 1)
        self.setToolTip(tip)
        self.slider.valueChanged.connect(self._slider_moved)
        self.edit.valueChanged.connect(self._edited)

    def set_range(self, lo, hi):
        self.lo, self.hi = float(lo), float(hi)
        self.edit.blockSignals(True)
        self.edit.setRange(self.lo, self.hi)
        self.edit.blockSignals(False)
        self.set_value(self._value)

    def set_value(self, v):
        self._value = clamp(float(v), self.lo, self.hi)
        self.edit.blockSignals(True)
        self.slider.blockSignals(True)
        self.edit.setValue(self._value)
        span = self.hi - self.lo
        self.slider.setValue(jsround((self._value - self.lo) / span * self.steps) if span > 0 else 0)
        self.edit.blockSignals(False)
        self.slider.blockSignals(False)

    def value(self):
        return self._value

    def _slider_moved(self, pos):
        v = round(self.lo + (self.hi - self.lo) * pos / self.steps, self.prec)
        self._value = clamp(v, self.lo, self.hi)
        self.edit.blockSignals(True)
        self.edit.setValue(self._value)
        self.edit.blockSignals(False)
        self.on_change(self._value)

    def _edited(self, v):
        self._value = clamp(float(v), self.lo, self.hi)
        span = self.hi - self.lo
        self.slider.blockSignals(True)
        self.slider.setValue(jsround((self._value - self.lo) / span * self.steps) if span > 0 else 0)
        self.slider.blockSignals(False)
        self.on_change(self._value)


class HaloSwatterDialog(QDialog):
    def __init__(self, engine, bridge=None, store=None):
        super().__init__(None)
        self.engine = engine
        self.bridge = bridge
        self.store = store
        self.updating = False
        self.note = ""
        self.applied = False
        self.setWindowTitle(HR_TITLE)
        self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True)
        fm = QFontMetrics(self.font())
        lw = fm.horizontalAdvance("Halo radius:") + 10

        def make_numeric(label, lo, hi, prec, steps, tip, change):
            def cb(v):
                if self.updating:
                    return
                h = engine.current_halo()
                if not h:
                    return
                change(h, v)
                self.note = ""
                self.halo_changed()
            return NumericControl(self, label, lo, hi, prec, steps, tip, lw, cb)

        # ---- Halo list ---------------------------------------------------------
        self.haloList = QTreeWidget(self)
        self.haloList.setColumnCount(4)
        self.haloList.setHeaderLabels(["#", "Position", "Radius", "Amount"])
        self.haloList.setRootIsDecorated(False)
        self.haloList.setAlternatingRowColors(True)
        self.haloList.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.haloList.setMinimumHeight(110)
        self.haloList.setColumnWidth(0, 30)
        self.haloList.setColumnWidth(1, 90)
        self.haloList.setColumnWidth(2, 55)
        self.haloList.currentItemChanged.connect(self._list_current_changed)

        self.removeButton = QPushButton("Remove", self)
        self.removeButton.clicked.connect(self._remove_clicked)
        self.clearButton = QPushButton("Clear all", self)
        self.clearButton.clicked.connect(self._clear_clicked)

        self.autoDetectCheck = QCheckBox("Find halo size automatically on click", self)
        self.autoDetectCheck.setChecked(True)
        self.autoDetectCheck.setToolTip(
            "Clicking a star measures its halo automatically.<br/>"
            "Dragging from a star outward sets the size by hand instead.<br/>"
            "Tip: zoom in so the halo fills about half of the preview; the search is limited to what you see.")

        lb = QHBoxLayout()
        lb.setSpacing(4)
        lb.addWidget(self.removeButton)
        lb.addWidget(self.clearButton)
        lb.addStretch(1)
        self.halosGroup = QGroupBox("Halos", self)
        hg = QVBoxLayout(self.halosGroup)
        hg.setContentsMargins(6, 6, 6, 6)
        hg.setSpacing(4)
        hg.addWidget(self.haloList, 100)
        hg.addLayout(lb)
        hg.addWidget(self.autoDetectCheck)

        # ---- Selected halo -----------------------------------------------------
        def set_rout(h, v):
            h.rOut = v
            if h.rIn > 0.9 * v:
                h.rIn = 0.9 * v

        def set_rin(h, v):
            h.rIn = min(v, 0.9 * h.rOut)

        def set_amount(h, v):
            h.amount = v

        def set_feather(h, v):
            h.feather = v

        self.rOutCtl = make_numeric("Halo radius:", 3, HR_SLIDER_MAX, 1, 2 * (HR_SLIDER_MAX - 3),
                                    "Radius where the halo has faded into the background (solid circle).",
                                    set_rout)
        self.rInCtl = make_numeric("Star radius:", 0, HR_SLIDER_MAX, 1, 2 * HR_SLIDER_MAX,
                                   "Core of the star (dashed circle), where it is saturated or not yet a "
                                   "smooth falloff.<br/>Outside of it the star's own light is modelled (a "
                                   "smooth power-law falloff) and kept;<br/>only the extra halo light is "
                                   "removed, also underneath the star.", set_rin)
        self.amountCtl = make_numeric("Amount:", 0, 1, 2, 100,
                                      "1 removes the halo completely, smaller values only reduce it.",
                                      set_amount)
        self.featherCtl = make_numeric("Feather:", 0, 1, 2, 100,
                                       "How softly the correction fades out towards the halo's edge.<br/>"
                                       "Higher values blend the corrected area more smoothly into the "
                                       "background,<br/>lower values also correct light right up to the "
                                       "edge (e.g. a bright rim).", set_feather)
        self.chromaCheck = QCheckBox("Only remove the halo's color", self)
        self.chromaCheck.setToolTip("Color images only, for this halo. Removes just the tint of the halo<br/>"
                                    "(e.g. a blue fringe) and keeps its neutral brightness.")
        self.chromaCheck.toggled.connect(self._chroma_toggled)

        self.paramGroup = QGroupBox("Selected halo", self)
        pg = QVBoxLayout(self.paramGroup)
        pg.setContentsMargins(6, 6, 6, 6)
        pg.setSpacing(4)
        for w in (self.rOutCtl, self.rInCtl, self.amountCtl, self.featherCtl, self.chromaCheck):
            pg.addWidget(w)

        # ---- Help + About ----------------------------------------------------------
        self.helpLabel = QLabel(self)
        self.helpLabel.setWordWrap(True)
        self.helpLabel.setTextFormat(Qt.TextFormat.RichText)
        self.helpLabel.setText(
            "<b>Click a star</b>: add a halo (size found automatically) &middot; "
            "<b>Drag from a star</b>: add a halo, size by hand<br/>"
            "<b>Click a halo's star</b>: select it &middot; <b>Drag its circle</b>: resize &middot; "
            "<b>Shift+drag its star</b> or drag the small center mark: move the halo's center &middot; "
            "<b>Del</b>: remove selected<br/>"
            "<b>Wheel</b>: zoom &middot; <b>Middle</b>/<b>Alt</b>+drag: pan &middot; "
            "<b>Space</b>: original/preview")
        self.aboutLabel = QLabel(self)
        self.aboutLabel.setWordWrap(True)
        self.aboutLabel.setTextFormat(Qt.TextFormat.RichText)
        self.aboutLabel.setOpenExternalLinks(True)
        self.aboutLabel.setText(
            "<b>%s</b> %s &nbsp;&ndash;&nbsp; by %s<br/><a href=\"%s\">%s</a><br/>"
            "Removes star halos. Works best on linear (unstretched) data."
            % (HR_TITLE, HR_VERSION, HR_AUTHOR, HR_WEBSITE, HR_WEBSITE))
        self.aboutLabel.setToolTip("%s %s, MIT License. Contact: %s" % (HR_TITLE, HR_VERSION, HR_CONTACT))
        self.aboutGroup = QGroupBox("About", self)
        ag = QVBoxLayout(self.aboutGroup)
        ag.setContentsMargins(6, 6, 6, 6)
        ag.addWidget(self.aboutLabel)

        # ---- Preview options --------------------------------------------------
        self.fitButton = QPushButton("Fit", self)
        self.fitButton.setToolTip("Show the whole image.")
        self.fitButton.clicked.connect(lambda: self.preview.fit())
        self.oneButton = QPushButton("1:1", self)
        self.oneButton.setToolTip("Zoom to one image pixel per screen pixel.")
        self.oneButton.clicked.connect(lambda: self.preview.zoom_one_to_one())
        self.haloZoomButton = QPushButton("Zoom to halo", self)
        self.haloZoomButton.setToolTip("Zoom in on the selected halo, including its background ring.")
        self.haloZoomButton.clicked.connect(lambda: self.preview.zoom_to_halo(engine.current_halo()))
        self.zoomLabel = QLabel("Fit", self)
        self.zoomLabel.setMinimumWidth(fm.horizontalAdvance("Fit (1:10.5)") + 8)
        self.zoomLabel.setToolTip("Current zoom. Mouse wheel zooms around the cursor; middle-drag or Alt+drag pans.")
        self.originalCheck = QCheckBox("Show original", self)
        self.originalCheck.setToolTip("Toggle between the original and the preview.<br/>"
                                      "Shortcut: <b>Space</b> (click the preview once so it has keyboard focus).")
        self.originalCheck.toggled.connect(lambda on: self.preview.set_show_original(on))
        self.showCombo = QComboBox(self)
        self.showCombo.addItems(["Result", "Removed halo"])
        self.showCombo.setToolTip("What the preview shows when it is not showing the original.<br/>"
                                  "\"Removed halo\" shows only what will be subtracted. "
                                  "Check it for stars or nebulosity, which shouldn't be there.")
        self.showCombo.currentIndexChanged.connect(self._show_selected)
        self.stretchCombo = QComboBox(self)
        self.stretchCombo.addItems(["Linear", "Auto STF", "Strong STF"])
        self.stretchCombo.setCurrentIndex(1)
        self.stretchCombo.setToolTip("Display stretch for the preview only. The image itself is not "
                                     "stretched.<br/>Strong STF makes faint halo residuals easier to see.")
        self.stretchCombo.currentIndexChanged.connect(self._stretch_selected)
        self.overlayCheck = QCheckBox("Overlay", self)
        self.overlayCheck.setChecked(True)
        self.overlayCheck.toggled.connect(self._overlay_toggled)

        opt = QHBoxLayout()
        opt.setSpacing(6)
        for w in (self.fitButton, self.oneButton, self.haloZoomButton, self.zoomLabel):
            opt.addWidget(w)
        opt.addSpacing(8)
        for w in (self.originalCheck, self.showCombo, self.stretchCombo, self.overlayCheck):
            opt.addWidget(w)
        opt.addStretch(1)

        self.preview = HaloPreview(engine, self)

        self.statusLabel = QLabel("", self)
        self.cursorLabel = QLabel("", self)
        self.cursorLabel.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
        st = QHBoxLayout()
        st.setSpacing(6)
        st.addWidget(self.statusLabel, 100)
        st.addWidget(self.cursorLabel)

        # ---- Bottom buttons ---------------------------------------------------
        self.newImageCheck = QCheckBox("Create new image (keep original untouched)", self)
        self.newImageCheck.setToolTip("Saves the result as a new FITS file next to the original "
                                      "(<i>name</i>_halo.fit) and leaves the image loaded in Siril unchanged.")
        if bridge is not None and not bridge.can_save_new():
            self.newImageCheck.setVisible(False)
        self.okButton = QPushButton("Apply", self)
        self.okButton.setDefault(False)
        self.okButton.setAutoDefault(False)
        self.okButton.clicked.connect(self._apply_clicked)
        self.cancelButton = QPushButton("Close", self)
        self.cancelButton.setAutoDefault(False)
        self.cancelButton.clicked.connect(self.reject)
        bottom = QHBoxLayout()
        bottom.setSpacing(6)
        bottom.addWidget(self.newImageCheck)
        bottom.addStretch(1)
        bottom.addWidget(self.okButton)
        bottom.addWidget(self.cancelButton)

        # ---- Layout -------------------------------------------------------------
        left = QVBoxLayout()
        left.setSpacing(6)
        left.addWidget(self.aboutGroup)
        left.addWidget(self.halosGroup, 100)
        left.addWidget(self.paramGroup)
        left.addWidget(self.helpLabel)
        leftW = QWidget(self)
        leftW.setLayout(left)
        leftW.setFixedWidth(max(360, lw + 320))
        left.setContentsMargins(0, 0, 0, 0)
        right = QVBoxLayout()
        right.setSpacing(4)
        right.addLayout(opt)
        right.addWidget(self.preview, 100)
        right.addLayout(st)
        top = QHBoxLayout()
        top.setSpacing(8)
        top.addWidget(leftW)
        top.addLayout(right, 100)
        main = QVBoxLayout(self)
        main.setContentsMargins(8, 8, 8, 8)
        main.setSpacing(8)
        main.addLayout(top, 100)
        main.addLayout(bottom)

        # Enter in a value field must not press a button (QDialog's autoDefault behaviour)
        for b in self.findChildren(QPushButton):
            b.setAutoDefault(False)
            b.setDefault(False)

        self._load_options()
        self.rebuild_list()
        self.update_controls()
        self.set_status(self.default_status())
        self.resize(1280, 820)
        self.preview.setFocus()

    # ---- Options persistence -----------------------------------------------------

    def _load_options(self):
        st = self.store
        if st is None:
            return
        self.autoDetectCheck.setChecked(st.get("autoDetect", True))
        self.overlayCheck.setChecked(st.get("overlay", True))
        self.stretchCombo.setCurrentIndex(clamp(st.get("stretch", 1), 0, 2))
        geo = st.get("geometry", None)
        if geo is not None:
            try:
                self.restoreGeometry(geo)
            except Exception:
                pass

    def _save_options(self):
        st = self.store
        if st is None:
            return
        try:
            st.set("autoDetect", self.autoDetectCheck.isChecked())
            st.set("overlay", self.overlayCheck.isChecked())
            st.set("stretch", self.stretchCombo.currentIndex())
            st.set("geometry", self.saveGeometry())
        except Exception:
            pass

    def done(self, r):
        # Close button, Esc, window close and Apply all end here (QDialog routes them to done())
        self.engine.save_settings()
        self._save_options()
        super().done(r)

    # ---- Slots -------------------------------------------------------------------

    def _list_current_changed(self, current, _prev):
        if self.updating or current is None:
            return
        idx = self.haloList.indexOfTopLevelItem(current)
        if idx >= 0 and idx != self.engine.current:
            self.engine.current = idx
            self.note = ""
            self.update_controls()
            self.preview.ensure_visible(self.engine.current_halo())
            self.preview.schedule_regenerate()

    def _remove_clicked(self):
        self.engine.remove_current()
        self.halos_changed()

    def _clear_clicked(self):
        self.engine.halos = []
        self.engine.current = -1
        self.halos_changed()

    def _chroma_toggled(self, checked):
        if self.updating:
            return
        h = self.engine.current_halo()
        if not h:
            return
        h.chromaOnly = checked
        self.halo_changed()

    def _show_selected(self, idx):
        self.preview.displayMode = 2 if idx == 1 else 0
        self.preview.set_show_original(False)
        self.preview.schedule_regenerate()

    def _stretch_selected(self, idx):
        self.preview.stretchMode = idx
        self.preview.schedule_regenerate()

    def _overlay_toggled(self, on):
        self.preview.showOverlay = on
        self.preview.update()

    def _apply_clicked(self):
        eng = self.engine
        if not eng.halos:
            box = QMessageBox(QMessageBox.Icon.Warning, HR_TITLE, "No halos defined yet.",
                              QMessageBox.StandardButton.Ok, self)
            box.open()       # not exec(): never blocks the event loop
            self._last_box = box
            return
        eng.save_settings()
        self.set_status("Applying...")
        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
        QApplication.processEvents()
        try:
            if self.bridge is not None:
                self.bridge.apply(eng, self.newImageCheck.isChecked() and self.newImageCheck.isVisible())
            self.applied = True
        except Exception as e:
            QApplication.restoreOverrideCursor()
            self.set_status("Error: %s" % e)
            self.log("%s: apply failed: %s" % (HR_TITLE, e))
            return
        QApplication.restoreOverrideCursor()
        self.accept()

    # ---- Helpers --------------------------------------------------------------

    def log(self, text):
        if self.bridge is not None:
            self.bridge.log(text)
        else:
            print(text)

    def set_status(self, text):
        self.statusLabel.setText(text)

    def clear_note(self):
        self.note = ""

    def update_zoom_label(self):
        if getattr(self, "preview", None) is not None:
            self.zoomLabel.setText(self.preview.zoom_text())

    def update_original_check(self):
        self.originalCheck.blockSignals(True)
        self.originalCheck.setChecked(self.preview.showOriginal)
        self.originalCheck.blockSignals(False)

    def detect_current(self):
        """Auto-detect the current halo's size around its star, searching within the visible area."""
        eng = self.engine
        h = eng.current_halo()
        if not h:
            return
        v = self.preview.viewData
        vis = min(v.rw, v.rh) if v else min(eng.W, eng.H)
        rMax = min(400, 0.25 * min(eng.W, eng.H), max(40, 0.6 * vis))
        self.set_status("Measuring halo...")
        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
        QApplication.processEvents()
        g = False
        try:
            g = eng.detect(h, 4, rMax)
        except Exception as e:
            self.log("%s: detection failed: %s" % (HR_TITLE, e))
        finally:
            QApplication.restoreOverrideCursor()
        if g:
            self.note = "Halo %d added: radius %.0f px. Drag its circle to change it." % (eng.current + 1, h.rOut)
            self.halo_changed()
        else:
            # Nothing to remove around this star: don't add a halo (it would only take away
            # light that belongs to something else). Dragging still adds one by hand.
            eng.remove_current()
            self.halos_changed()
            self.note = "No halo found around this star. Drag outward from it to add one by hand."
            self.set_status(self.note)

    def default_status(self):
        n = len(self.engine.halos)
        if self.note:
            return self.note
        if n == 0:
            return "Click a star with a halo in the preview to add it."
        return "%d halo%s defined." % (n, "s" if n > 1 else "")

    def show_cursor_info(self, p, hint):
        eng = self.engine
        if p[0] < 0 or p[1] < 0 or p[0] >= eng.W or p[1] >= eng.H:
            self.cursorLabel.setText("")
            return
        t = "x %.0f  y %.0f" % (p[0], p[1])
        self.cursorLabel.setText(hint + "   |   " + t if hint else t)

    def row_texts(self, i):
        h = self.engine.halos[i]
        return [str(i + 1), "%.0f, %.0f" % (h.sx, h.sy), "%.0f" % h.rOut, "%.2f" % h.amount]

    def rebuild_list(self):
        self.updating = True
        self.haloList.clear()
        for i in range(len(self.engine.halos)):
            QTreeWidgetItem(self.haloList, self.row_texts(i))
        if 0 <= self.engine.current < len(self.engine.halos):
            self.haloList.setCurrentItem(self.haloList.topLevelItem(self.engine.current))
        self.updating = False

    def update_list_row(self, i):
        if i < 0 or i >= self.haloList.topLevelItemCount():
            return
        it = self.haloList.topLevelItem(i)
        for c, t in enumerate(self.row_texts(i)):
            it.setText(c, t)

    def update_controls(self):
        eng = self.engine
        h = eng.current_halo()
        self.updating = True
        # Sliders end at 200 px; a halo dragged larger extends the range for itself
        self.rOutCtl.set_range(3, max(HR_SLIDER_MAX, int(math.ceil(h.rOut)) if h else 0))
        self.rInCtl.set_range(0, max(HR_SLIDER_MAX, int(math.ceil(h.rIn)) if h else 0))
        if h:
            self.rOutCtl.set_value(h.rOut)
            self.rInCtl.set_value(h.rIn)
            self.amountCtl.set_value(h.amount)
            self.featherCtl.set_value(h.feather)
        self.paramGroup.setEnabled(h is not None)
        self.removeButton.setEnabled(h is not None)
        self.clearButton.setEnabled(len(eng.halos) > 0)
        self.chromaCheck.setEnabled(eng.nc == 3 and h is not None)
        self.chromaCheck.setChecked(h is not None and h.chromaOnly)
        self.updating = False

    def halo_changed(self):
        self.update_list_row(self.engine.current)
        self.update_controls()
        self.preview.schedule_regenerate()

    def halos_changed(self):
        self.note = ""
        self.rebuild_list()
        self.update_controls()
        self.preview.schedule_regenerate()

    def showEvent(self, ev):
        super().showEvent(ev)
        self.update_zoom_label()
        self.preview.schedule_regenerate()


# ============================================================================
# Main
# ============================================================================

_app = None


def qt_app():
    """The QApplication, created once and kept for the whole process. Held only in a local
    variable it was destroyed when that function returned, and the dialog's C++ object
    with it ("wrapped C/C++ object ... has been deleted")."""
    global _app
    if _app is None:
        _app = QApplication.instance() or QApplication(sys.argv)
        _app.setApplicationName(HR_TITLE)
    return _app


def create_dialog(siril):
    """Reads the loaded image from Siril and builds the dialog (not shown yet)."""
    bridge = SirilBridge(siril)
    img, is_real, bits = bridge.load()
    qt_app()
    store = QtSettingsStore()
    engine = HaloEngine()
    engine.settings = store
    engine.set_image(img, is_real, bits)
    return HaloSwatterDialog(engine, bridge, store)


def main():
    siril = s.SirilInterface()
    try:
        siril.connect()
    except Exception as e:
        print("%s: could not connect to Siril: %s" % (HR_TITLE, e))
        return 1
    try:
        if not siril.is_image_loaded():
            siril.error_messagebox("Please open an image first.")
            return 1
        dlg = create_dialog(siril)
        dlg.exec()     # like PixInsight's dialog.execute(): returns when the dialog closes
        return 0
    except Exception as e:
        try:
            siril.error_messagebox("%s: %s" % (HR_TITLE, e))
        except Exception:
            print("%s: %s" % (HR_TITLE, e))
        return 1
    finally:
        try:
            siril.disconnect()
        except Exception:
            pass


if __name__ == "__main__":
    sys.exit(main())
