from datetime import datetime
from pathlib import Path
from uuid import UUID

import numpy as np
import numpy.typing as npt
import plotly.express as px  # type: ignore
import plotly.graph_objects as go  # type: ignore
import sep  # type: ignore
from astropy.coordinates import SkyCoord  # type: ignore
from astropy.io import fits  # type: ignore
from astropy.visualization import AsinhStretch, ZScaleInterval  # type: ignore
from astropy.wcs import WCS  # type: ignore
from astropy.wcs.utils import fit_wcs_from_points  # type: ignore
from keplemon.bodies import Constellation, Observatory
from keplemon.elements import TopocentricElements
from keplemon.enums import ReferenceFrame
from keplemon.time import Epoch, TimeSpan
from numpy.lib import recfunctions as rfn
from pydantic import BaseModel, Field

from pixelemon._plate_solve import PlateSolve, TetraSolver
from pixelemon._telescope import Telescope
from pixelemon.constants import BASE_TO_KILO, KILO_TO_BASE, PERCENT_TO_DECIMAL, SUN_MAGNITUDE
from pixelemon.correlation import CorrelatedDetection, CorrelationCandidate, CorrelationCandidates, CorrelationSettings
from pixelemon.logging import PixeLemonLog
from pixelemon.processing import (
    MIN_BACKGROUND_MESH_COUNT,
    BackgroundSettings,
    Detection,
    DetectionSettings,
    PixeLemonObservation,
    PixeLemonObservations,
    SEPDetections,
)
from pixelemon.utils import map_outside_std_threshold, map_within_std_threshold


class TelescopeImage(BaseModel):
    _original_array: npt.NDArray[np.float32] | None = None
    _processed_array: npt.NDArray[np.float32] | None = None
    _plate_solve: PlateSolve | None = None
    _detections: SEPDetections | None = None
    _background: sep.Background | None = None
    _background_removed: bool = False
    epoch: datetime | None = None
    telescope: Telescope | None = None
    exposure_time: float | None = None
    _ground_site: Observatory | None = None
    _wcs: WCS | None = None
    image_scale: float = Field(default=1.0, description="The image scale due to cropping")
    background_settings: BackgroundSettings = Field(default=BackgroundSettings())
    detection_settings: DetectionSettings = Field(default=DetectionSettings.streak_source_defaults())
    correlation_settings: CorrelationSettings = Field(default=CorrelationSettings())

    @classmethod
    def from_fits_file(cls, file_path: Path, telescope: Telescope) -> "TelescopeImage":
        with fits.open(file_path) as hdul:
            img = cls()
            assert hasattr(hdul[0], "header")
            header = getattr(hdul[0], "header")
            img.exposure_time = header["EXPTIME"]
            img._wcs = WCS(header)
            img.epoch = datetime.fromisoformat(header["DATE-OBS"])
            img.telescope = telescope
            img._original_array = getattr(hdul[0], "data").astype(np.float32)

            if "SITELAT" in header and "SITELONG" in header and "SITEALT" in header:
                lat = header["SITELAT"]
                lon = header["SITELONG"]
                h = header["SITEALT"] * BASE_TO_KILO
            elif "OBSGEO-L" in header and "OBSGEO-B" in header and "OBSGEO-H" in header:
                lon = header["OBSGEO-L"]
                lat = header["OBSGEO-B"]
                h = header["OBSGEO-H"] * BASE_TO_KILO
            else:
                site_msg = "(SITELAT, SITELONG, and SITEALT)"
                geo_msg = "(OBSGEO-L, OBSGEO-B, and OBSGEO-H)"
                raise ValueError(f"Location not found in FITS header. Expected either {site_msg} or {geo_msg}.")

            img._ground_site = Observatory(lat, lon, h)
            assert img._original_array is not None
            actual_ratio = img._original_array.shape[1] / img._original_array.shape[0]
            if not np.isclose(img.telescope.aspect_ratio, actual_ratio, atol=1e-6):
                new_width = int(img._original_array.shape[0] * img.telescope.aspect_ratio)
                img._original_array = img._original_array[:, 0:new_width]
                img._original_array = np.ascontiguousarray(img._original_array)
                new_shape = img._original_array.shape
                PixeLemonLog().debug(f"Trimmed {file_path} to expected {new_shape[0]}h x {new_shape[1]}w")
            assert img._original_array is not None
            img._processed_array = img._original_array.copy()
        return img

    def get_correlation_candidates(self, sats: Constellation) -> CorrelationCandidates:
        if self._ground_site is None:
            raise ValueError("Ground site is not set.")
        solve = self.plate_solve
        if solve is None:
            return CorrelationCandidates([])
        elif self.epoch is None:
            raise ValueError("Image epoch is not set.")
        elif self.exposure_time is None:
            raise ValueError("Exposure time is not set.")

        kepoch = Epoch.from_datetime(self.epoch) + TimeSpan.from_seconds(self.exposure_time * 0.5)

        sats_in_fov = self._ground_site.get_field_of_view_report(
            kepoch,
            TopocentricElements.from_j2000(
                kepoch,
                solve.right_ascension,
                solve.declination,
            ),
            self.horizontal_field_of_view * 0.5,
            sats,
            ReferenceFrame.J2000,
        )

        correlation_candidates = CorrelationCandidates([])
        for start_candidate in sats_in_fov.candidates:

            r = start_candidate.direction.range
            assert r is not None
            r = r * KILO_TO_BASE
            area = self.detection_settings.satellite_area
            albedo = self.detection_settings.satellite_albedo
            vis_mag = SUN_MAGNITUDE + 2.5 * np.log10((4 * np.pi * r**2) / (albedo * area))
            if vis_mag <= self.detection_settings.limiting_magnitude:
                sat_range = start_candidate.direction.range
                assert sat_range is not None

                xc, yc = self.get_fits_pixels(
                    start_candidate.direction.right_ascension,
                    start_candidate.direction.declination,
                )

                correlation_candidate = CorrelationCandidate(
                    id=start_candidate.satellite_id,
                    x_centroid=xc,
                    y_centroid=yc,
                    range=sat_range,
                )
                correlation_candidates.root.append(correlation_candidate)

        return correlation_candidates

    def get_angular_separation(self, x0: float, y0: float, x1: float, y1: float) -> float:
        if self._wcs is None:
            raise ValueError("WCS is not set.")
        sky0 = self._wcs.pixel_to_world(x0, y0)
        sky1 = self._wcs.pixel_to_world(x1, y1)
        return sky0.separation(sky1).deg

    @property
    def star_elongation_histogram(self) -> go.Histogram:
        return go.Histogram(
            x=self.stars.elongation_array,
            xbins=dict(
                start=self.detections.elongation_array.min(),
                end=self.detections.elongation_array.max(),
                size=np.std(self.detections.elongation_array) / 10,
            ),
            name="Star Elongations",
            marker_color="yellow",
            autobinx=False,
            opacity=0.75,
        )

    @property
    def star_pixel_count_histogram(self) -> go.Histogram:
        return go.Histogram(
            x=self.stars.pixel_count_array,
            autobinx=False,
            xbins=dict(
                start=self.detections.pixel_count_array.min(),
                end=self.detections.pixel_count_array.max(),
                size=np.std(self.detections.pixel_count_array) / 10,
            ),
            name="Star Pixel Count Histogram",
            marker_color="yellow",
            opacity=0.75,
        )

    @property
    def satellite_elongation_histogram(self) -> go.Histogram:
        return go.Histogram(
            x=self.satellites.elongation_array,
            autobinx=False,
            xbins=dict(
                start=self.detections.elongation_array.min(),
                end=self.detections.elongation_array.max(),
                size=np.std(self.detections.elongation_array) / 10,
            ),
            name="Satellite Elongations",
            marker_color="red",
            opacity=0.75,
        )

    @property
    def satellite_pixel_count_histogram(self) -> go.Histogram:
        return go.Histogram(
            x=self.satellites.pixel_count_array,
            autobinx=False,
            xbins=dict(
                start=self.detections.pixel_count_array.min(),
                end=self.detections.pixel_count_array.max(),
                size=np.std(self.detections.pixel_count_array) / 10,
            ),
            name="Satellite Pixel Count Histogram",
            marker_color="red",
            opacity=0.75,
        )

    @property
    def elongation_histogram(self) -> go.Histogram:
        return go.Histogram(
            x=self.detections.elongation_array,
            autobinx=False,
            xbins=dict(
                start=self.detections.elongation_array.min(),
                end=self.detections.elongation_array.max(),
                size=np.std(self.detections.elongation_array) / 10,
            ),
            name="All Detections",
            marker_color="gray",
            opacity=0.75,
        )

    @property
    def pixel_count_histogram(self) -> go.Histogram:
        return go.Histogram(
            x=self.detections.pixel_count_array,
            autobinx=False,
            xbins=dict(
                start=self.detections.pixel_count_array.min(),
                end=self.detections.pixel_count_array.max(),
                size=np.std(self.detections.pixel_count_array) / 10,
            ),
            name="All Detections",
            marker_color="gray",
            opacity=0.75,
        )

    def plot_elongation_histogram(self):
        fig = go.Figure()
        fig.add_trace(self.elongation_histogram)
        fig.add_trace(self.star_elongation_histogram)
        fig.add_trace(self.satellite_elongation_histogram)
        fig.show(config={"staticPlot": False})

    def plot_pixel_count_histogram(self):
        fig = go.Figure()
        fig.add_trace(self.pixel_count_histogram)
        fig.add_trace(self.star_pixel_count_histogram)
        fig.add_trace(self.satellite_pixel_count_histogram)
        fig.show(config={"staticPlot": False})

    @property
    def satellites_scatter(self) -> go.Scatter:
        yx = self.satellites.y_x_array
        xs, ys = yx[:, 1], self.height - 1 - yx[:, 0]
        return go.Scatter(
            x=xs, y=ys, mode="markers", marker=dict(color="red", size=5, symbol="x"), name="Possible Satellites"
        )

    @property
    def stars_scatter(self) -> go.Scatter:
        yx = self.stars.y_x_array
        xs, ys = yx[:, 1], self.height - 1 - yx[:, 0]
        return go.Scatter(
            x=xs, y=ys, mode="markers", marker=dict(color="yellow", size=5, symbol="circle"), name="Possible Stars"
        )

    @property
    def matched_stars_scatter(self) -> go.Scatter:
        if self.plate_solve is None:
            raise ValueError("Plate solve is not set.")
        yx = self.plate_solve.matched_centroids
        x, y = yx[:, 1], self.height - 1 - yx[:, 0]
        return go.Scatter(
            x=x, y=y, mode="markers", marker=dict(color="green", size=7, symbol="star"), name="Matched Stars"
        )

    def get_candidates_scatter(self, sats: Constellation) -> go.Scatter:
        candidates = self.get_correlation_candidates(sats)
        xy = np.array([(c.x_centroid, c.y_centroid) for c in candidates.root], dtype=np.float64)
        labels = [sats[c.id].name for c in candidates.root]
        return go.Scatter(
            x=xy[:, 0],
            y=xy[:, 1],
            mode="markers",
            marker=dict(color="blue", size=5, symbol="diamond"),
            name="Candidates",
            text=labels,
            textposition="top center",
        )

    def plot(self, sats: Constellation):

        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        elif self.epoch is None:
            raise ValueError("Image epoch is not set.")

        interval = ZScaleInterval()
        vmin, vmax = interval.get_limits(self._processed_array)
        stretch = AsinhStretch()
        data_stretched = stretch((np.clip(self._processed_array, vmin, vmax) - vmin) / (vmax - vmin))
        data = np.flipud(data_stretched)

        fig = px.imshow(data, color_continuous_scale="Viridis", origin="lower", title=f"{self.epoch.isoformat()} Image")

        fig.add_trace(self.satellites_scatter)
        fig.add_trace(self.stars_scatter)
        fig.add_trace(self.matched_stars_scatter)
        fig.add_trace(self.get_candidates_scatter(sats))
        fig.update_traces(showscale=False, selector=dict(type="heatmap"))
        fig.show(config={"staticPlot": False})

    def get_angles(self, x: float, y: float) -> tuple[float, float]:
        if self._wcs is None:
            raise ValueError("WCS is not set.")
        sky = self._wcs.pixel_to_world(x, y)
        return sky.ra.deg, sky.dec.deg

    def get_correlation_to_candidate(self, candidate: CorrelationCandidate) -> CorrelatedDetection | None:
        detections = self.satellites

        det_x, det_y = detections.x_array, self.height - 1 - detections.y_array
        delta_pixels = candidate.x_centroid - det_x, candidate.y_centroid - det_y
        distances = np.hypot(delta_pixels[0], delta_pixels[1])
        assert self.telescope is not None
        pixel_to_degree = self.telescope.diagonal_pixel_scale
        angular_distances = distances * pixel_to_degree
        valid_angles = angular_distances[angular_distances <= self.correlation_settings.centroid_angle_limit]

        if len(valid_angles) == 0:
            PixeLemonLog().debug("No detections within centroid angle limit")
            return None
        else:
            best_i = int(np.argmin(angular_distances))

        y, x = detections.y_x_array[best_i]
        ra_dec = self.get_angles(x, y)
        zp = self.zero_point
        assert zp is not None
        return CorrelatedDetection(
            satellite_id=candidate.id,
            right_ascension=ra_dec[0],
            declination=ra_dec[1],
            visual_magnitude=detections.instrumental_magnitude_array[best_i] + zp,
            angle_between_centroids=angular_distances[best_i],
            cross_line_of_sight_range=candidate.range * np.deg2rad(angular_distances[best_i]),
        )

    def get_correlations(self, sats: Constellation) -> list[CorrelatedDetection]:
        detections = self.satellites
        candidates = self.get_correlation_candidates(sats)
        if self._wcs is None:
            raise ValueError("WCS is not set.")
        elif len(candidates) == 0:
            PixeLemonLog().info("No correlation candidates in field of view")
            return []
        elif detections.count == 0:
            PixeLemonLog().info("No satellites detected in image")
            return []

        # Pixels -> sky in one shot
        det_xy = np.column_stack((detections.x_array, self.height - 1 - detections.y_array))
        sat_xy = np.array([(c.x_centroid, c.y_centroid) for c in candidates], dtype=np.float64)
        det_sky = self._wcs.pixel_to_world(det_xy[:, 0], det_xy[:, 1])
        sat_sky = self._wcs.pixel_to_world(sat_xy[:, 0], sat_xy[:, 1])

        # Pairwise angular distances (deg)
        distances = det_sky[:, None].separation(sat_sky[None, :]).deg
        limit = self.correlation_settings.centroid_angle_limit
        distances = np.where(distances <= limit, distances, np.inf)

        order = np.argsort(distances, axis=None)
        det_used = np.zeros(detections.count, dtype=bool)
        sat_used = np.zeros(len(candidates), dtype=bool)
        correlations: list[CorrelatedDetection] = []
        zp = self.zero_point
        if zp is None:
            return correlations

        for flat_idx in order:
            det_idx = flat_idx // len(candidates)
            sat_idx = flat_idx % len(candidates)
            angle = float(distances[det_idx, sat_idx])
            if not np.isfinite(angle):
                break
            if det_used[det_idx] or sat_used[sat_idx]:
                continue

            det_used[det_idx] = True
            sat_used[sat_idx] = True

            cand = candidates[sat_idx]
            ra, dec = det_sky[det_idx].ra.deg, det_sky[det_idx].dec.deg
            correlations.append(
                CorrelatedDetection(
                    satellite_id=cand.id,
                    right_ascension=ra,
                    declination=dec,
                    visual_magnitude=float(detections.instrumental_magnitude_array[det_idx]) + zp,
                    angle_between_centroids=angle,
                    cross_line_of_sight_range=cand.range * np.deg2rad(angle),
                )
            )

        PixeLemonLog().info(f"Correlated {len(correlations)} of {self.satellites.count} detections")

        return correlations

    def get_observations(self, sats: Constellation) -> PixeLemonObservations:
        correlations = self.get_correlations(sats)
        observations = []
        if self.epoch is None:
            raise ValueError("Image epoch is not set.")
        elif self._ground_site is None:
            raise ValueError("Ground site is not set.")
        elif self.telescope is None:
            raise ValueError("Telescope is not set.")

        for corr in correlations:
            observation = PixeLemonObservation(
                satellite_id=UUID(corr.satellite_id),
                epoch=self.epoch,
                right_ascension=corr.right_ascension,
                declination=corr.declination,
                sensor_latitude=self._ground_site.latitude,
                sensor_longitude=self._ground_site.longitude,
                sensor_altitude=(self._ground_site.altitude * KILO_TO_BASE),
                angular_noise=self.telescope.diagonal_pixel_scale,
                visual_magnitude=corr.visual_magnitude,
            )
            observations.append(observation)
        PixeLemonLog().info(f"Generated {len(observations)} observations")
        return PixeLemonObservations(observations)

    def get_correlation_to_detection(self, det: Detection, sats: CorrelationCandidates) -> CorrelatedDetection | None:

        xc, yc = det.x_centroid, self.height - 1 - det.y_centroid
        angular_distances = []

        for candidate in sats:
            angle = self.get_angular_separation(xc, yc, candidate.x_centroid, candidate.y_centroid)
            if angle < self.correlation_settings.centroid_angle_limit:
                angular_distances.append(angle)

        if not angular_distances:
            PixeLemonLog().debug("No candidates within centroid angle limit")
            return None
        elif len(angular_distances) == 1:
            best_i = 0
        else:
            best_i = int(np.argmin(angular_distances))

        candidate = sats[best_i]
        ra_dec = self.get_angles(det.x_centroid, det.y_centroid)
        zp = self.zero_point
        assert zp is not None
        return CorrelatedDetection(
            satellite_id=candidate.id,
            right_ascension=ra_dec[0],
            declination=ra_dec[1],
            visual_magnitude=det.get_visual_magnitude(zp),
            angle_between_centroids=angular_distances[best_i],
            cross_line_of_sight_range=candidate.range * np.deg2rad(angular_distances[best_i]),
        )

    def write_to_fits_file(self, file_path: Path):
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        fits.writeto(file_path, self._processed_array.astype("uint8"), overwrite=True)

    def dampen_hot_rows(self):
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        row_means = np.mean(self._processed_array, axis=1)
        global_mean = np.mean(row_means)
        global_std = np.std(row_means)
        threshold = global_mean + self.detection_settings.detection_sigma * global_std
        for i, row_mean in enumerate(row_means):
            if row_mean > threshold:
                PixeLemonLog().debug(f"Dampening hot row {i} with mean {row_mean:.2f}")
                self._processed_array[i, :] *= global_mean / row_mean

    def crop(self, crop_percent: float):
        if self._original_array is None:
            raise ValueError("Image array is not loaded.")

        crop_fraction = crop_percent * PERCENT_TO_DECIMAL
        height, width = self._original_array.shape
        crop_height = int(height * crop_fraction / 2)
        crop_width = int(width * crop_fraction / 2)
        self._processed_array = np.ascontiguousarray(
            self._original_array[crop_height : height - crop_height, crop_width : width - crop_width]
        )
        self.image_scale = self.image_scale * (1.0 - crop_fraction)
        self._plate_solve = None
        self._satellites = None
        self._segmentation_map = None
        self._star_segments = None
        self._stars = None
        self._background = None
        self._background_removed = False

    @property
    def background(self) -> sep.Background:
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")

        if self._background is None:

            self.dampen_hot_rows()

            bw = max(
                MIN_BACKGROUND_MESH_COUNT, int(self._processed_array.shape[1] / self.background_settings.mesh_count)
            )
            bh = max(
                MIN_BACKGROUND_MESH_COUNT, int(self._processed_array.shape[0] / self.background_settings.mesh_count)
            )

            self._background = sep.Background(
                self._processed_array,
                bw=bw,
                bh=bh,
                fw=self.background_settings.filter_size,
                fh=self.background_settings.filter_size,
                fthresh=self.background_settings.detection_threshold,
            )

        return self._background

    def remove_background(self):
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        self._processed_array = self._processed_array - self.background
        self._background_removed = True

    def reset(self):
        if self._original_array is None:
            raise ValueError("Image array is not loaded.")
        self._processed_array = self._original_array.copy()
        self.image_scale = 1.0
        self._background = None
        self._detections = None
        self._plate_solve = None
        self._background_removed = False

    def get_fits_pixels(self, ra: float, dec: float) -> tuple[float, float]:
        if self._wcs is None:
            raise ValueError("WCS is not set.")
        sky = SkyCoord(ra, dec, unit="deg")
        x, y = self._wcs.world_to_pixel(sky)
        return x, y

    @property
    def horizontal_field_of_view(self) -> float:
        if self.telescope is None:
            raise ValueError("Telescope is not set.")
        return self.telescope.horizontal_field_of_view * self.image_scale

    @property
    def vertical_field_of_view(self) -> float:
        if self.telescope is None:
            raise ValueError("Telescope is not set.")
        return self.telescope.vertical_field_of_view * self.image_scale

    @property
    def diagonal_field_of_view(self) -> float:
        if self.telescope is None:
            raise ValueError("Telescope is not set.")
        return self.telescope.diagonal_field_of_view * self.image_scale

    @property
    def detections(self) -> SEPDetections:
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")

        if not self._background_removed:
            self.remove_background()

        if self._detections is None:
            objects = sep.extract(
                self._processed_array,
                thresh=self.detection_settings.detection_sigma * self.background.globalrms,
                minarea=self.detection_settings.min_pixel_count,
                filter_kernel=self.detection_settings.gaussian_kernel,
                deblend_nthresh=self.detection_settings.deblend_mesh_count,
                deblend_cont=self.detection_settings.deblend_contrast,
                clean=self.detection_settings.merge_small_detections,
                segmentation_map=False,
            )

            # add instrumental magnitude to the objects
            instrumental_mag = -2.5 * np.log10(objects["flux"] / self.exposure_time)
            objects = rfn.append_fields(objects, "inst_mag", instrumental_mag, usemask=False)

            self._detections = SEPDetections(objects)

        return self._detections

    @property
    def stars(self) -> SEPDetections:
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")

        PixeLemonLog().debug(f"{self.detections.count} total detections before star filtering")
        elongation_map = map_within_std_threshold(
            self.detections.elongation_array,
            self.detection_settings.star_elongation_threshold,
            use_mad=True,
        )
        PixeLemonLog().debug(f"{len(elongation_map[elongation_map])} star-like objects passed elongation filter")
        return SEPDetections(self.detections._objects[elongation_map])

    @property
    def satellites(self):
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")

        PixeLemonLog().debug(f"{self.detections.count} total detections before satellite filtering")
        elongation_map = map_outside_std_threshold(
            self.detections.elongation_array,
            self.detection_settings.satellite_elongation_threshold,
            use_mad=True,
        )
        PixeLemonLog().debug(f"{len(elongation_map[elongation_map])} satellite-like objects passed elongation filter")
        return SEPDetections(self.detections._objects[elongation_map])

    @property
    def plate_solve(self) -> PlateSolve | None:
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        if self.telescope is None:
            raise ValueError("Telescope is not set.")

        if self._plate_solve is None:

            plate_solve = TetraSolver().solve_from_centroids(
                self.stars.y_x_array,
                img_size=(self.height, self.width),
                fov=self.diagonal_field_of_view,
            )

            if plate_solve is not None:

                pixel_scale = self.telescope.horizontal_pixel_scale
                assert self._wcs is not None

                # seed the WCS to improve chances of solution with fit_wcs_from_points
                self._wcs.wcs.crpix = [self._processed_array.shape[1] / 2, self._processed_array.shape[0] / 2]
                self._wcs.wcs.crval = [plate_solve.right_ascension, plate_solve.declination]
                self._wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
                self._wcs.wcs.cunit = ["deg", "deg"]
                theta = np.deg2rad(-plate_solve.roll)
                cd11 = -pixel_scale * np.cos(theta)
                cd12 = pixel_scale * np.sin(theta)
                cd21 = pixel_scale * np.sin(theta)
                cd22 = pixel_scale * np.cos(theta)
                self._wcs.wcs.cd = np.array([[cd11, cd12], [cd21, cd22]], dtype=float)

                # invert matched centroids for WCS fitting
                yx = plate_solve.matched_centroids
                x, y = yx[:, 1], self.height - 1 - yx[:, 0]
                ra_dec = plate_solve.matched_stars

                # fit WCS from matched stars
                sky = SkyCoord(ra=ra_dec[:, 0], dec=ra_dec[:, 1], unit="deg")
                self._wcs = fit_wcs_from_points((x, y), sky, sip_degree=5, proj_point="center")
                self._plate_solve = plate_solve
                PixeLemonLog().info(f"Matched {plate_solve.number_of_stars} of {self.stars.count} stars")

        return self._plate_solve

    @property
    def height(self) -> int:
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        return self._processed_array.shape[0]

    @property
    def width(self) -> int:
        if self._processed_array is None:
            raise ValueError("Image array is not loaded.")
        return self._processed_array.shape[1]

    @property
    def zero_point(self) -> float | None:
        solve = self.plate_solve
        if solve is None:
            return None

        PixeLemonLog().debug(f"Calculating zero point from {solve.matched_centroids.shape[0]} matched stars")
        tetra_yx = solve.matched_centroids
        stars_yx = self.stars.y_x_array
        inst_mag = self.stars.instrumental_magnitude_array

        delta = stars_yx[:, None, :] - tetra_yx[None, :, :]
        distance = np.linalg.norm(delta, axis=2)

        closest_star_idx = np.argmin(distance, axis=0)
        closest_dist = distance[closest_star_idx, np.arange(distance.shape[1])]
        match_mask = closest_dist <= self.detection_settings.max_star_pixel_distance

        if not match_mask.any():
            return None

        matched_star_idx = closest_star_idx[match_mask]
        matched_tetra_idx = np.arange(tetra_yx.shape[0])[match_mask]

        offsets = solve.matched_stars[matched_tetra_idx, 2] - inst_mag[matched_star_idx]
        PixeLemonLog().debug(f"Zero point calculated from {len(offsets)} matched stars")
        PixeLemonLog().debug(f"Max distance for matched stars: {closest_dist[match_mask].max():.12f} pixels")
        return float(offsets.mean())
