"""Shared vision primitives: ROI crop, perceptual hash, pixel↔price interp, Hough lines."""
from __future__ import annotations

from dataclasses import dataclass

import cv2
import numpy as np

from .config import ROI, YAxisCalib


def crop_roi(img: np.ndarray, roi: ROI) -> np.ndarray:
    """Crop RGB/BGR image by ROI. Raises on out-of-bounds."""
    h, w = img.shape[:2]
    if roi.x + roi.w > w or roi.y + roi.h > h:
        raise ValueError(f"ROI {roi} exceeds image {w}x{h}")
    return img[roi.y:roi.y + roi.h, roi.x:roi.x + roi.w].copy()


def phash(img: np.ndarray, size: int = 16) -> str:
    """Perceptual hash via DCT. Returns hex string. Image may be RGB/BGR/gray."""
    if img.ndim == 3:
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    else:
        gray = img
    small = cv2.resize(gray, (32, 32), interpolation=cv2.INTER_AREA).astype(np.float32)
    dct = cv2.dct(small)
    block = dct[:size, :size]
    med = np.median(block[1:, 1:])  # skip DC term
    bits = (block > med).flatten()
    # pack into hex
    out = 0
    for b in bits:
        out = (out << 1) | int(b)
    return f"{out:0{len(bits) // 4}x}"


def hamming_hex(a: str, b: str) -> int:
    if len(a) != len(b):
        raise ValueError("hash length mismatch")
    return bin(int(a, 16) ^ int(b, 16)).count("1")


def pixel_y_to_price(y: int, calib: YAxisCalib) -> float:
    """Linear interp from screen-y to price. y grows downward; prices usually invert."""
    dy = calib.p2_y - calib.p1_y
    dp = calib.p2_price - calib.p1_price
    return calib.p1_price + (y - calib.p1_y) * (dp / dy)


def price_to_pixel_y(price: float, calib: YAxisCalib) -> int:
    dy = calib.p2_y - calib.p1_y
    dp = calib.p2_price - calib.p1_price
    return int(round(calib.p1_y + (price - calib.p1_price) * (dy / dp)))


@dataclass(frozen=True)
class ColorMatch:
    name: str
    distance: float
    confidence: float  # 1 - d_nearest / d_second


def classify_pixel(
    rgb: tuple[int, int, int],
    palette: dict[str, tuple[tuple[int, int, int], float]],
) -> ColorMatch:
    """Nearest-color in RGB Euclidean. palette: name → (rgb, tolerance).

    Returns UNKNOWN if nearest exceeds its tolerance.
    """
    best_name = "UNKNOWN"
    best_d = float("inf")
    second_d = float("inf")
    best_tol = 0.0
    p = np.array(rgb, dtype=np.float32)
    for name, (ref, tol) in palette.items():
        d = float(np.linalg.norm(p - np.array(ref, dtype=np.float32)))
        if d < best_d:
            second_d = best_d
            best_d = d
            best_name = name
            best_tol = tol
        elif d < second_d:
            second_d = d
    if best_d > best_tol:
        return ColorMatch(name="UNKNOWN", distance=best_d, confidence=0.0)
    conf = 1.0 - (best_d / second_d) if second_d > 0 else 1.0
    return ColorMatch(name=best_name, distance=best_d, confidence=max(0.0, conf))


def find_rightmost_dot(
    roi_img: np.ndarray,
    bg_rgb: tuple[int, int, int],
    bg_tol: float = 15.0,
    min_cluster_px: int = 3,
) -> tuple[int, int] | None:
    """Scan ROI right→left; return (x, y) of rightmost non-background cluster center.

    roi_img is BGR (OpenCV convention). bg_rgb is RGB.
    """
    bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
    diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2)
    mask = (diff > bg_tol).astype(np.uint8)

    # Anti-aliasing bridges between adjacent dots can fuse the entire strip
    # into one blob under 8-connectivity. Erode by 2px to sever those bridges
    # before labelling — dots shrink but remain well above min_cluster_px.
    kernel = np.ones((3, 3), dtype=np.uint8)
    mask = cv2.erode(mask, kernel, iterations=2)
    n_labels, _labels, stats, centroids = cv2.connectedComponentsWithStats(
        mask, connectivity=8,
    )
    best_idx: int | None = None
    best_right = -1
    for i in range(1, n_labels):  # skip background (label 0)
        if int(stats[i, cv2.CC_STAT_AREA]) < min_cluster_px:
            continue
        right = int(stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH])
        if right > best_right:
            best_right = right
            best_idx = i
    if best_idx is None:
        return None
    # When erosion fails to sever anti-aliased bridges between adjacent dots
    # (common on long, dense dot rows), the "rightmost" component spans
    # several fused dots and its centroid lands on an interior dot — wrong
    # colour. Detect fused blobs by width and anchor to the right edge
    # instead; small isolated dots still use the centroid.
    comp_w = int(stats[best_idx, cv2.CC_STAT_WIDTH])
    right_edge = int(stats[best_idx, cv2.CC_STAT_LEFT]) + comp_w - 1
    if comp_w > 12:
        cx = max(right_edge - 2, 0)
    else:
        cx = int(centroids[best_idx][0])
    cy = int(centroids[best_idx][1])
    return (cx, cy)


def find_top_dots(
    roi_img: np.ndarray,
    bg_rgb: tuple[int, int, int],
    bg_tol: float = 15.0,
    min_cluster_px: int = 3,
    n: int = 3,
) -> list[tuple[int, int]]:
    """Top-N rightmost non-background clusters as (cx, cy), sorted by right edge desc.

    Same mask/erode/connectedComponents pipeline as `find_rightmost_dot`, but collects
    all qualifying components and returns the top N by right-edge. Tie-break on equal
    right_edge: smaller y first (deterministic for tests). Anchor logic identical —
    fused blobs (comp_w > 12) anchor to `right_edge-2`, small isolated dots use centroid.
    """
    bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
    diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2)
    mask = (diff > bg_tol).astype(np.uint8)

    kernel = np.ones((3, 3), dtype=np.uint8)
    mask = cv2.erode(mask, kernel, iterations=2)
    n_labels, _labels, stats, centroids = cv2.connectedComponentsWithStats(
        mask, connectivity=8,
    )
    candidates: list[tuple[int, int, int, int]] = []  # (-right_edge, y, cx, cy)
    for i in range(1, n_labels):  # skip background
        if int(stats[i, cv2.CC_STAT_AREA]) < min_cluster_px:
            continue
        comp_w = int(stats[i, cv2.CC_STAT_WIDTH])
        right_edge = int(stats[i, cv2.CC_STAT_LEFT]) + comp_w - 1
        if comp_w > 12:
            cx = max(right_edge - 2, 0)
        else:
            cx = int(centroids[i][0])
        cy = int(centroids[i][1])
        candidates.append((-right_edge, cy, cx, cy))
    candidates.sort()  # desc by right_edge, asc by y
    return [(cx, cy) for (_neg_r, _y, cx, cy) in candidates[:n]]


def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]:
    """Sample mean RGB of a (2*box+1)² patch around (x,y). Input BGR → returns RGB."""
    h, w = roi_img.shape[:2]
    x0, x1 = max(0, x - box), min(w, x + box + 1)
    y0, y1 = max(0, y - box), min(h, y + box + 1)
    patch = roi_img[y0:y1, x0:x1]
    mean = patch.reshape(-1, 3).mean(axis=0)
    # BGR → RGB
    return (int(mean[2]), int(mean[1]), int(mean[0]))


def detect_color_lines(
    chart_img: np.ndarray,
    color_rgb: tuple[int, int, int],
    tol: float = 30.0,
    min_line_length_frac: float = 0.3,
) -> list[int]:
    """Find horizontal lines of given color. Returns list of y-coords (sorted).

    chart_img is BGR. Color mask + Hough for near-horizontal lines.
    """
    _h, w = chart_img.shape[:2]
    bgr_target = np.array([color_rgb[2], color_rgb[1], color_rgb[0]], dtype=np.float32)
    diff = np.linalg.norm(chart_img.astype(np.float32) - bgr_target, axis=2)
    mask = (diff < tol).astype(np.uint8) * 255
    if mask.sum() == 0:
        return []
    min_len = max(10, int(w * min_line_length_frac))
    lines = cv2.HoughLinesP(
        mask,
        rho=1, theta=np.pi / 180,
        threshold=min_len,
        minLineLength=min_len,
        maxLineGap=int(w * 0.05),
    )
    if lines is None:
        return []
    ys: list[int] = []
    for _x1, y1, _x2, y2 in lines[:, 0]:
        if abs(y2 - y1) <= 2:  # near-horizontal
            ys.append(int((y1 + y2) / 2))
    # merge near-duplicates within 3 px
    ys.sort()
    merged: list[int] = []
    for y in ys:
        if merged and abs(y - merged[-1]) <= 3:
            continue
        merged.append(y)
    return merged
