"""Tip shape estimation — estimate SPM tip geometry from known calibration features."""

from __future__ import annotations

import numpy as np

from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable


@register_node(display_name="Tip Shape Estimate")
class TipShapeEstimate:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "field": ("DATA_FIELD",),
                "feature_type": (["edge", "sphere", "cylinder"], {"default": "edge"}),
                "feature_radius": ("FLOAT", {
                    "default": 100e-9, "min": 1e-9, "max": 100e-6, "step": 1e-9,
                }),
                "n_points": ("INT", {"default": 100, "min": 10, "max": 1000}),
            }
        }

    OUTPUTS = (
        ('DATA_FIELD', 'tip_shape'),
        ('RECORD_TABLE', 'parameters'),
    )
    FUNCTION = "process"

    DESCRIPTION = (
        "Estimate SPM tip geometry from a known calibration feature. "
        "Supported features: edge (sharp step), sphere (calibration ball), "
        "cylinder (calibration wire). The image of a known feature is a "
        "dilation of the feature with the tip; by subtracting the known "
        "feature contribution the tip shape can be recovered. "
        "The 2D tip is built by revolving the 1D radial profile (axial "
        "symmetry assumption). Output parameters include estimated tip "
        "radius of curvature at the apex and half-cone angle. "
    )

    KEYWORDS = ("calibration", "villarrubia", "tipshape", "radius", "apex", "half angle", "edge", "sphere", "cylinder", "dilation")

    def process(
        self,
        field: DataField,
        feature_type: str,
        feature_radius: float,
        n_points: int,
    ) -> tuple:
        data = field.data.astype(np.float64)
        ny, nx = data.shape
        pixel_size = (field.dx + field.dy) * 0.5

        # ── Step 1: Extract 1D tip profile depending on feature type ──────

        if feature_type == "edge":
            tip_profile_1d = self._estimate_from_edge(data, pixel_size, n_points)

        elif feature_type == "sphere":
            tip_profile_1d = self._estimate_from_sphere(
                data, pixel_size, feature_radius, n_points,
            )

        elif feature_type == "cylinder":
            tip_profile_1d = self._estimate_from_cylinder(
                data, pixel_size, feature_radius, n_points,
            )

        else:
            raise ValueError(
                f"Unknown feature_type {feature_type!r}. "
                "Choose: edge, sphere, cylinder."
            )

        # ── Step 2: Build 2D tip by revolution (axial symmetry) ───────────

        n_tip = n_points if n_points % 2 == 1 else n_points + 1
        ci = n_tip // 2
        offsets = np.arange(n_tip) - ci
        gx, gy = np.meshgrid(offsets, offsets)
        r_grid = np.sqrt(gx ** 2 + gy ** 2)

        # The 1D profile goes from r = 0 to r = max_r.
        # Interpolate for every pixel in the 2D grid.
        r_1d = np.linspace(0, ci, len(tip_profile_1d))
        tip_2d = np.interp(r_grid, r_1d, tip_profile_1d, right=0.0)

        # Convention: apex (centre) is the maximum; minimum is 0.
        tip_2d -= tip_2d.min()

        xreal = n_tip * pixel_size
        tip_field = DataField(
            data=tip_2d,
            xreal=xreal,
            yreal=xreal,
            si_unit_xy=field.si_unit_xy,
            si_unit_z=field.si_unit_z,
        )

        # ── Step 3: Estimate tip parameters ───────────────────────────────

        tip_radius, half_angle = self._estimate_parameters(
            tip_profile_1d, pixel_size,
        )

        table = RecordTable([
            {"quantity": "tip_radius", "value": tip_radius, "unit": field.si_unit_z},
            {"quantity": "half_angle", "value": half_angle, "unit": "deg"},
        ])

        return (tip_field, table)

    # ── Feature-specific estimation methods ───────────────────────────────

    @staticmethod
    def _estimate_from_edge(
        data: np.ndarray,
        pixel_size: float,
        n_points: int,
    ) -> np.ndarray:
        """
        Edge feature: the tip shape is the mirror of the steepest edge
        cross-section in the image.

        Find the row with the maximum gradient magnitude, extract the
        cross-section at that location, mirror and normalise.

        Returns a radial profile: index 0 = apex (maximum), decreasing
        outward.
        """
        ny, nx = data.shape

        # Compute row-wise gradient magnitude to find the steepest edge.
        grad = np.abs(np.diff(data, axis=1))
        row_grad = grad.sum(axis=1)
        best_row = int(np.argmax(row_grad))

        profile = data[best_row, :]

        # Mirror: the tip is the complement of the edge profile.
        tip_raw = np.max(profile) - profile[::-1]

        # Find the peak of the mirrored profile and take the radial
        # (half) profile from apex outward.
        peak = int(np.argmax(tip_raw))
        # Use the longer side from the peak to preserve resolution.
        left = tip_raw[:peak + 1][::-1]   # apex to left edge, reversed
        right = tip_raw[peak:]             # apex to right edge
        half = left if len(left) >= len(right) else right
        half = half.copy()
        # Ensure monotonically decreasing from apex.
        for i in range(1, len(half)):
            if half[i] > half[i - 1]:
                half[i] = half[i - 1]

        # Resample to n_points.
        x_raw = np.linspace(0, 1, len(half))
        x_out = np.linspace(0, 1, n_points)
        tip_profile = np.interp(x_out, x_raw, half)

        return tip_profile

    @staticmethod
    def _estimate_from_sphere(
        data: np.ndarray,
        pixel_size: float,
        radius: float,
        n_points: int,
    ) -> np.ndarray:
        """
        Sphere feature: dilation model z_measured = z_sphere (+) z_tip.

        Extract radial profile from the highest point and subtract the
        ideal sphere contribution to recover the tip profile.

        Returns a radial profile: index 0 = apex (maximum), decreasing
        outward.
        """
        ny, nx = data.shape

        # Find the highest point (apex of the imaged sphere).
        peak_idx = np.unravel_index(np.argmax(data), data.shape)
        cy, cx = peak_idx

        # Extract radial profile by averaging azimuthally.
        max_r = min(cy, ny - 1 - cy, cx, nx - 1 - cx)
        if max_r < 2:
            max_r = min(ny, nx) // 2

        Y, X = np.ogrid[:ny, :nx]
        r_map = np.sqrt(((X - cx) * pixel_size) ** 2 + ((Y - cy) * pixel_size) ** 2)

        n_bins = min(max_r, n_points)
        r_edges = np.linspace(0, max_r * pixel_size, n_bins + 1)
        radial_profile = np.zeros(n_bins)
        for i in range(n_bins):
            mask = (r_map >= r_edges[i]) & (r_map < r_edges[i + 1])
            if mask.any():
                radial_profile[i] = data[mask].mean()
            elif i > 0:
                radial_profile[i] = radial_profile[i - 1]

        r_centres = 0.5 * (r_edges[:-1] + r_edges[1:])

        # Ideal sphere profile: z_sphere(r) = sqrt(R^2 - r^2) for r < R, else 0.
        sphere_profile = np.where(
            r_centres < radius,
            np.sqrt(np.maximum(radius ** 2 - r_centres ** 2, 0.0)),
            0.0,
        )

        # Tip profile = measured - sphere (dilation subtraction).
        tip_raw = radial_profile - sphere_profile
        # Shift so that the apex (index 0) is the maximum.
        tip_raw = tip_raw - tip_raw.min()
        # Ensure monotonically decreasing from apex outward by clamping.
        for i in range(1, len(tip_raw)):
            if tip_raw[i] > tip_raw[i - 1]:
                tip_raw[i] = tip_raw[i - 1]

        # Resample to n_points.
        x_raw = np.linspace(0, 1, len(tip_raw))
        x_out = np.linspace(0, 1, n_points)
        tip_profile = np.interp(x_out, x_raw, tip_raw)

        return tip_profile

    @staticmethod
    def _estimate_from_cylinder(
        data: np.ndarray,
        pixel_size: float,
        radius: float,
        n_points: int,
    ) -> np.ndarray:
        """
        Cylinder feature: extract cross-section perpendicular to the
        cylinder axis and subtract ideal cylinder profile.

        The cylinder axis is assumed to run along the direction with the
        least height variation.

        Returns a radial profile: index 0 = apex (maximum), decreasing
        outward.
        """
        ny, nx = data.shape

        # Determine cylinder axis: compare row-wise vs column-wise variance.
        row_var = np.var(np.diff(data, axis=1))
        col_var = np.var(np.diff(data, axis=0))

        if row_var > col_var:
            # Cylinder axis along columns -> cross-section along rows.
            profile = data.mean(axis=0)
        else:
            # Cylinder axis along rows -> cross-section along columns.
            profile = data.mean(axis=1)

        n_prof = len(profile)
        peak = int(np.argmax(profile))

        # Ideal cylinder cross-section: z = sqrt(R^2 - x^2) for |x| < R.
        x_phys = (np.arange(n_prof) - peak) * pixel_size
        cyl_profile = np.where(
            np.abs(x_phys) < radius,
            np.sqrt(np.maximum(radius ** 2 - x_phys ** 2, 0.0)),
            0.0,
        )

        # Tip = measured - cylinder.
        tip_raw = profile - cyl_profile
        tip_raw -= tip_raw.min()

        # Take the radial (half) profile from the peak outward.
        left = tip_raw[:peak + 1][::-1]
        right = tip_raw[peak:]
        half = left if len(left) >= len(right) else right
        # Ensure monotonically decreasing from apex.
        for i in range(1, len(half)):
            if half[i] > half[i - 1]:
                half[i] = half[i - 1]

        # Resample to n_points.
        x_raw = np.linspace(0, 1, len(half))
        x_out = np.linspace(0, 1, n_points)
        tip_profile = np.interp(x_out, x_raw, half)

        return tip_profile

    # ── Parameter estimation ──────────────────────────────────────────────

    @staticmethod
    def _estimate_parameters(
        tip_profile: np.ndarray,
        pixel_size: float,
    ) -> tuple[float, float]:
        """
        Estimate tip radius of curvature at the apex and half-cone angle
        from the 1D radial profile.

        tip_radius: fitted from the parabolic approximation near the apex,
            z(r) ~ z_max - r^2 / (2R)  =>  R = r^2 / (2 * (z_max - z(r)))

        half_angle: from the slope of the tip walls in the outer half,
            tan(half_angle) = dz/dr  =>  half_angle = arctan(slope)
        """
        n = len(tip_profile)
        r = np.linspace(0, (n - 1) * pixel_size, n)

        # ── Tip radius from apex curvature ────────────────────────────────
        # Use a few points near the apex for a parabolic fit: z = a - b*r^2
        n_apex = max(3, n // 10)
        r_apex = r[:n_apex]
        z_apex = tip_profile[:n_apex]

        if len(r_apex) >= 2 and r_apex[-1] > 0:
            # Fit z = c0 + c1 * r^2
            A = np.vstack([np.ones(n_apex), r_apex ** 2]).T
            try:
                coeffs = np.linalg.lstsq(A, z_apex, rcond=None)[0]
                c1 = coeffs[1]
                # z = z_max - r^2/(2R) => c1 = -1/(2R) => R = -1/(2*c1)
                if c1 < 0:
                    tip_radius = -1.0 / (2.0 * c1)
                else:
                    tip_radius = float('inf')
            except np.linalg.LinAlgError:
                tip_radius = float('inf')
        else:
            tip_radius = float('inf')

        # ── Half-angle from outer wall slope ──────────────────────────────
        # Use the outer 50% of the profile.
        mid = n // 2
        if mid < n - 1:
            r_outer = r[mid:]
            z_outer = tip_profile[mid:]
            if len(r_outer) >= 2:
                dr = r_outer[-1] - r_outer[0]
                dz = z_outer[-1] - z_outer[0]
                if dr > 0:
                    slope = abs(dz / dr)
                    half_angle = np.degrees(np.arctan(slope))
                else:
                    half_angle = 0.0
            else:
                half_angle = 0.0
        else:
            half_angle = 0.0

        return tip_radius, half_angle
