from pathlib import Path
from typing import Any
from urllib.request import urlretrieve

import numpy as np
import numpy.typing as npt
import tetra3  # type: ignore
from pydantic import BaseModel, Field

from pixelemon.constants import ZERO
from pixelemon.logging import PixeLemonLog

_GH_URL = "https://github.com/citra-space/pixelemon/releases/download/general-purpose-database"
TETRA_DB_NAME = "tyc_db_to_40_deg.npz"
TETRA_DATABASE_URL = f"{_GH_URL}/{TETRA_DB_NAME}"
TETRA_DATABASE_PATH = Path(__file__).parent / TETRA_DB_NAME


class TetraSolver(tetra3.Tetra3):

    _instance: "TetraSolver | None" = None
    _initialized: bool = False

    def __new__(cls, *args, **kwargs) -> "TetraSolver":
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, database_path: Path | None = None, reload: bool = False):
        if not self._initialized or reload:
            if database_path is None:
                super().__init__()
            else:
                super().__init__(load_database=database_path.as_posix())
            self.settings = TetraSettings.model_validate(self.database_properties)
            self._initialized = True

    @classmethod
    def low_memory(cls) -> "TetraSolver":
        """Instantiate TetraSolver for wider fields of view and stars up to 8 magnitude."""
        solver = cls(reload=True)
        return solver

    @classmethod
    def high_memory(cls) -> "TetraSolver":
        """Instantiate TetraSolver for smaller fields of view and stars up to 10 magnitude.

        !!! note
            This method is more general purpose but requires downloading a larger database file.
        """
        if not TETRA_DATABASE_PATH.exists():
            PixeLemonLog().warning("Tetra database not found locally.  One-time download may take several minutes.")
            PixeLemonLog().info(f"Downloading Tetra database from {_GH_URL} to {TETRA_DATABASE_PATH}")
            urlretrieve(TETRA_DATABASE_URL, TETRA_DATABASE_PATH.as_posix())
        solver = cls(TETRA_DATABASE_PATH, reload=True)
        return solver

    def solve_from_centroids(
        self,
        star_centroids: npt.NDArray[np.float32],
        img_size: tuple[int, int],
        fov: float,
    ) -> "PlateSolve | None":
        """Perform a plate solve using the provided star centroids.

        Args:
            star_centroids: An array of shape (N, 2) containing the (y, x) pixel coordinates of detected stars.
            img_size: A tuple (height, width) representing the size of the image in pixels.
            fov: An estimate of the field of view in degrees.

        !!! note
            y coordinates are compatible with SEP convention (top-left origin).
        """
        try:
            result = super().solve_from_centroids(star_centroids, size=img_size, fov_estimate=fov, return_matches=True)
            solve = PlateSolve(result)
            PixeLemonLog().info(f"Completed plate solve in {solve.solve_time:.2f} ms")
        except ValueError as e:
            PixeLemonLog().error(e)
            return None
        return solve


class TetraSettings(BaseModel):
    dimmest_star_magnitude: float = Field(
        ...,
        description="The dimmest star magnitude to consider in the plate solve",
        alias="star_max_magnitude",
    )
    max_field_of_view: float = Field(
        ...,
        description="The maximum field of view in degrees the solver can reliably handle",
        alias="max_fov",
    )
    min_field_of_view: float = Field(
        ...,
        description="The minimum field of view in degrees the solver can reliably handle",
        alias="min_fov",
    )
    verification_star_count: int = Field(
        ...,
        description="The number of stars to use for verification of the plate solve",
        alias="verification_stars_per_fov",
    )

    def model_post_init(self, _: Any) -> None:
        PixeLemonLog().info(f"Tetra dimmest star magnitude set to {self.dimmest_star_magnitude}")
        PixeLemonLog().info(f"Tetra max field of view set to {self.max_field_of_view} degrees")
        PixeLemonLog().info(f"Tetra min field of view set to {self.min_field_of_view} degrees")
        PixeLemonLog().info(f"Tetra verification star count set to {self.verification_star_count}")


class PlateSolve:

    def __init__(self, tetra_output: dict) -> None:
        """Initialize PlateSolve from Tetra output dictionary.

        Args:
            tetra_output: The output dictionary from Tetra3 plate solve.

        Attributes:
            right_ascension (float): J2000 Right Ascension in degrees.
            declination (float): J2000 Declination in degrees.
            roll (float): Roll angle in degrees.
            estimated_horizontal_fov (float): Estimated horizontal field of view in degrees.
            root_mean_square_error (float): Root mean square error of the plate solve in arcseconds.
            number_of_stars (int): Number of stars matched in the plate solve.
            false_positive_probability (float): Probability that the plate solve is a false positive.
            solve_time (float): Time taken to perform the plate solve in milliseconds.
            matched_stars (np.ndarray): Array of matched star coordinates from the catalog.
            matched_centroids (np.ndarray): Array of matched star centroids from the image.
            distortion (float): Estimated distortion of the plate solve.
        """

        if tetra_output is None:
            raise ValueError("Plate solve failed")
        elif tetra_output["Prob"] > ZERO:
            raise ValueError("Plate solve returned false positive probability greater than zero")

        self.right_ascension: float = tetra_output["RA"]
        self.declination: float = tetra_output["Dec"]
        self.roll: float = tetra_output["Roll"]
        self.estimated_horizontal_fov: float = tetra_output["FOV"]
        self.root_mean_square_error: float = tetra_output["RMSE"]
        self.number_of_stars: int = tetra_output["Matches"]
        self.false_positive_probability: float = tetra_output["Prob"]
        self.solve_time: float = tetra_output["T_solve"]
        self.matched_stars: np.ndarray = np.array(tetra_output["matched_stars"])
        self.matched_centroids: np.ndarray = np.array(tetra_output["matched_centroids"])
        self.distortion: float = tetra_output["distortion"]
