from __future__ import annotations

from dataclasses import dataclass
from numbers import Real
from typing import Final
import math


# 기상청 공식 예제에서 사용하는 격자 범위입니다.
# 단기예보 API에서 일반적으로 사용되는 범위이므로 기본값으로 둡니다.
NX: Final[int] = 149
NY: Final[int] = 253


@dataclass(frozen=True, slots=True)
class LambertConformalConicConfig:
    """
    Lambert Conformal Conic 투영에 필요한 고정 파라미터를 담는 설정 객체입니다.

    기본값은 기상청 공식 예제의 값을 그대로 사용합니다.
    """

    earth_radius_km: float = 6371.00877
    grid_spacing_km: float = 5.0
    standard_latitude_1: float = 30.0
    standard_latitude_2: float = 60.0
    origin_longitude: float = 126.0
    origin_latitude: float = 38.0
    origin_x: float = 210.0 / 5.0
    origin_y: float = 675.0 / 5.0
    max_x: int = NX
    max_y: int = NY

    def __post_init__(self) -> None:
        """
        설정값이 잘못 들어오지 않았는지 생성 시점에 즉시 검증합니다.
        """

        for name, value in (
            ("earth_radius_km", self.earth_radius_km),
            ("grid_spacing_km", self.grid_spacing_km),
            ("standard_latitude_1", self.standard_latitude_1),
            ("standard_latitude_2", self.standard_latitude_2),
            ("origin_longitude", self.origin_longitude),
            ("origin_latitude", self.origin_latitude),
            ("origin_x", self.origin_x),
            ("origin_y", self.origin_y),
        ):
            if not isinstance(value, Real) or isinstance(value, bool):
                raise TypeError(f"{name} 값은 반드시 실수형 숫자여야 합니다.")
            if not math.isfinite(float(value)):
                raise ValueError(f"{name} 값은 반드시 유한한 숫자여야 합니다.")

        if self.earth_radius_km <= 0.0:
            raise ValueError("earth_radius_km 값은 0보다 커야 합니다.")

        if self.grid_spacing_km <= 0.0:
            raise ValueError("grid_spacing_km 값은 0보다 커야 합니다.")

        if not -90.0 < self.standard_latitude_1 < 90.0:
            raise ValueError("standard_latitude_1 값은 -90과 90 사이여야 합니다.")

        if not -90.0 < self.standard_latitude_2 < 90.0:
            raise ValueError("standard_latitude_2 값은 -90과 90 사이여야 합니다.")

        if self.max_x < 1 or self.max_y < 1:
            raise ValueError("max_x, max_y 값은 최소 1 이상이어야 합니다.")


@dataclass(frozen=True, slots=True)
class GridPoint:
    """
    기상청 API에 전달하는 정수 격자 좌표입니다.
    """

    x: int
    y: int


@dataclass(frozen=True, slots=True)
class GeoPoint:
    """
    일반적인 위경도 좌표입니다.
    """

    latitude: float
    longitude: float


@dataclass(frozen=True, slots=True)
class _ProjectionBasis:
    """
    반복 계산을 피하기 위해 미리 계산해 두는 내부 투영 상수입니다.
    """

    pi: float
    deg_to_rad: float
    rad_to_deg: float
    re: float
    olon_rad: float
    sn: float
    sf: float
    ro: float


class KmaGridConverter:
    """
    기상청 격자 <-> 위경도 변환기입니다.

    중요한 점:
    1) 공식 C 코드의 수식을 그대로 따르되, Python 스타일로 안전하게 정리했습니다.
    2) 격자화 시에는 공식 예제와 동일하게 int(x + 1.5) 규칙을 사용합니다.
    3) 역변환은 '격자점의 대표 위경도'를 구하는 것이므로, 원래 위경도와 완전 역함수가 아닐 수 있습니다.
    """

    def __init__(self, config: LambertConformalConicConfig | None = None) -> None:
        self.config = config if config is not None else LambertConformalConicConfig()
        self._basis = self._build_basis()

    def _build_basis(self) -> _ProjectionBasis:
        """
        공식 C 코드의 first == 0 블록에서 한 번만 계산하던 값을
        Python에서는 생성 시점에 미리 계산해 둡니다.
        """

        pi = math.pi
        deg_to_rad = pi / 180.0
        rad_to_deg = 180.0 / pi

        re = self.config.earth_radius_km / self.config.grid_spacing_km
        slat1 = self.config.standard_latitude_1 * deg_to_rad
        slat2 = self.config.standard_latitude_2 * deg_to_rad
        olon_rad = self.config.origin_longitude * deg_to_rad
        olat = self.config.origin_latitude * deg_to_rad

        sn = math.tan(pi * 0.25 + slat2 * 0.5) / math.tan(pi * 0.25 + slat1 * 0.5)
        sn = math.log(math.cos(slat1) / math.cos(slat2)) / math.log(sn)

        sf = math.tan(pi * 0.25 + slat1 * 0.5)
        sf = math.pow(sf, sn) * math.cos(slat1) / sn

        ro = math.tan(pi * 0.25 + olat * 0.5)
        ro = re * sf / math.pow(ro, sn)

        return _ProjectionBasis(
            pi=pi,
            deg_to_rad=deg_to_rad,
            rad_to_deg=rad_to_deg,
            re=re,
            olon_rad=olon_rad,
            sn=sn,
            sf=sf,
            ro=ro,
        )

    @staticmethod
    def _validate_real(name: str, value: Real) -> float:
        """
        숫자 타입인지, 그리고 유한한 값인지 확인합니다.

        bool 은 int 의 하위 타입이므로 따로 제외합니다.
        """

        if not isinstance(value, Real) or isinstance(value, bool):
            raise TypeError(f"{name} 값은 반드시 실수형 숫자여야 합니다.")

        numeric_value = float(value)

        if not math.isfinite(numeric_value):
            raise ValueError(f"{name} 값은 반드시 유한한 숫자여야 합니다.")

        return numeric_value

    @staticmethod
    def _normalize_longitude(longitude: float) -> float:
        """
        경도를 [-180, 180] 근처 범위로 정규화합니다.

        공식 C 코드는 theta 에 대해서만 한 번 보정하지만,
        Python 쪽에서는 입력 자체를 먼저 정리해 두는 편이 더 안전합니다.
        """

        normalized = (longitude + 180.0) % 360.0 - 180.0

        # +180도를 -180도로 바꾸지 않고 유지하고 싶을 때를 위한 보정입니다.
        if normalized == -180.0 and longitude > 0.0:
            return 180.0

        return normalized

    def _validate_latitude(self, latitude: Real) -> float:
        """
        위도는 투영 수식 특성상 -90, 90 극점 자체는 허용하지 않습니다.
        """

        numeric_latitude = self._validate_real("latitude", latitude)

        if not -90.0 < numeric_latitude < 90.0:
            raise ValueError("latitude 값은 -90과 90 사이의 개방구간이어야 합니다.")

        return numeric_latitude

    def _validate_grid(self, x: Real, y: Real, *, validate_bounds: bool) -> tuple[float, float]:
        """
        격자 좌표의 타입과 범위를 검증합니다.

        validate_bounds=True 이면 기상청 격자 범위 [1, NX], [1, NY] 도 함께 확인합니다.
        """

        numeric_x = self._validate_real("x", x)
        numeric_y = self._validate_real("y", y)

        if validate_bounds:
            if not 1.0 <= numeric_x <= float(self.config.max_x):
                raise ValueError(f"x 값은 [1, {self.config.max_x}] 범위 안에 있어야 합니다.")

            if not 1.0 <= numeric_y <= float(self.config.max_y):
                raise ValueError(f"y 값은 [1, {self.config.max_y}] 범위 안에 있어야 합니다.")

        return numeric_x, numeric_y

    def project(self, latitude: Real, longitude: Real) -> tuple[float, float]:
        """
        위경도를 '원시 투영 좌표'로 변환합니다.

        반환값은 아직 API용 정수 격자가 아니며,
        공식 C 코드의 lamcproj(..., code=0) 결과와 대응되는 부동소수점 좌표입니다.
        """

        lat = self._validate_latitude(latitude)
        lon = self._normalize_longitude(self._validate_real("longitude", longitude))
        basis = self._basis

        ra = math.tan(basis.pi * 0.25 + lat * basis.deg_to_rad * 0.5)
        ra = basis.re * basis.sf / math.pow(ra, basis.sn)

        theta = lon * basis.deg_to_rad - basis.olon_rad
        theta = (theta + basis.pi) % (2.0 * basis.pi) - basis.pi
        theta *= basis.sn

        x = ra * math.sin(theta) + self.config.origin_x
        y = basis.ro - ra * math.cos(theta) + self.config.origin_y

        return x, y

    def to_grid(self, latitude: Real, longitude: Real, *, validate_bounds: bool = True) -> GridPoint:
        """
        위경도를 기상청 API에서 사용하는 정수 격자로 변환합니다.

        여기서 사용하는 반올림 규칙은 공식 C 코드와 동일합니다.
        즉, 일반적인 round() 가 아니라 int(x + 1.5) 방식입니다.
        """

        x_float, y_float = self.project(latitude, longitude)

        x = int(x_float + 1.5)
        y = int(y_float + 1.5)

        if validate_bounds:
            self._validate_grid(x, y, validate_bounds=True)

        return GridPoint(x=x, y=y)

    def to_geo(self, x: Real, y: Real, *, validate_bounds: bool = True) -> GeoPoint:
        """
        기상청 정수 격자를 대표 위경도로 역변환합니다.

        공식 C 코드의 lamcproj(..., code=1) 흐름을 기준으로 옮겼습니다.
        """

        numeric_x, numeric_y = self._validate_grid(x, y, validate_bounds=validate_bounds)
        basis = self._basis

        # 공식 C 코드의 map_conv 에서는 역변환 전에 x, y 에서 각각 1을 빼고 시작합니다.
        x1 = numeric_x - 1.0
        y1 = numeric_y - 1.0

        xn = x1 - self.config.origin_x
        yn = basis.ro - y1 + self.config.origin_y

        ra = math.hypot(xn, yn)

        # 공식 C 예제에는 "if (sn < 0.0) -ra;" 라는 코드가 있는데,
        # 이는 대입이 빠진 사실상 no-op 입니다.
        # 의도된 의미대로 Python에서는 명시적으로 재대입합니다.
        if basis.sn < 0.0:
            ra = -ra

        if ra == 0.0:
            raise ValueError("주어진 격자 좌표는 ra 가 0이 되어 역변환할 수 없습니다.")

        alat = math.pow((basis.re * basis.sf / ra), (1.0 / basis.sn))
        alat = 2.0 * math.atan(alat) - basis.pi * 0.5

        # 공식 C 코드의 분기 구조를 읽기 쉽게 그대로 유지했습니다.
        if xn == 0.0:
            theta = 0.0
        elif yn == 0.0:
            theta = basis.pi * 0.5

            # 공식 C 예제에는 "if (xn < 0.0) -theta;" 라는 코드가 있는데,
            # 역시 대입이 빠진 no-op 입니다.
            # 의도대로 부호를 반전합니다.
            if xn < 0.0:
                theta = -theta
        else:
            theta = math.atan2(xn, yn)

        alon = theta / basis.sn + basis.olon_rad

        latitude = alat * basis.rad_to_deg
        longitude = self._normalize_longitude(alon * basis.rad_to_deg)

        return GeoPoint(latitude=latitude, longitude=longitude)


class Coordinate:
    """
    기존 라이브러리 인터페이스를 최대한 유지하기 위한 호환용 클래스입니다.

    기존 코드와의 차이:
    - gridX, gridY 는 이제 API에 바로 넣을 수 있는 정수 격자를 반환합니다.
    - lamcproj() 는 기존 이름을 유지하되, 원시 부동소수점 투영 좌표를 반환합니다.
    """

    __slots__ = ("latitude", "longitude", "_converter")

    def __init__(
        self,
        latitude: Real,
        longitude: Real,
        *,
        converter: KmaGridConverter | None = None,
    ) -> None:
        self._converter = converter if converter is not None else KmaGridConverter()

        # 생성 시점에 바로 검증해 두면 이후 속성 접근 시점에서 뒤늦게 터지지 않습니다.
        self.latitude = self._converter._validate_latitude(latitude)
        self.longitude = self._converter._normalize_longitude(
            self._converter._validate_real("longitude", longitude)
        )

    def lamcproj(self, lat: Real, lon: Real) -> dict[str, float]:
        """
        기존 메서드 이름을 유지하는 호환용 메서드입니다.

        반환값:
        - x, y: 공식 lamcproj 의 부동소수점 투영 좌표
        """

        x, y = self._converter.project(lat, lon)
        return {"x": x, "y": y}

    @property
    def grid(self) -> GridPoint:
        """
        현재 인스턴스의 위경도를 정수 격자로 변환한 결과를 반환합니다.
        """

        return self._converter.to_grid(self.latitude, self.longitude)

    @property
    def grid_x(self) -> int:
        """
        snake_case 스타일의 X 격자 접근자입니다.
        """

        return self.grid.x

    @property
    def grid_y(self) -> int:
        """
        snake_case 스타일의 Y 격자 접근자입니다.
        """

        return self.grid.y

    @property
    def gridX(self) -> int:
        """
        기존 camelCase 스타일을 유지한 X 격자 접근자입니다.
        """

        return self.grid_x

    @property
    def gridY(self) -> int:
        """
        기존 camelCase 스타일을 유지한 Y 격자 접근자입니다.
        """

        return self.grid_y


# 모듈 단위에서 바로 쓸 수 있는 기본 변환기입니다.
_DEFAULT_CONVERTER = KmaGridConverter()


def latlon_to_grid(
    latitude: Real,
    longitude: Real,
    *,
    validate_bounds: bool = True,
) -> GridPoint:
    """
    위경도를 기상청 정수 격자로 변환하는 편의 함수입니다.
    """

    return _DEFAULT_CONVERTER.to_grid(
        latitude=latitude,
        longitude=longitude,
        validate_bounds=validate_bounds,
    )


def grid_to_latlon(
    x: Real,
    y: Real,
    *,
    validate_bounds: bool = True,
) -> GeoPoint:
    """
    기상청 정수 격자를 대표 위경도로 역변환하는 편의 함수입니다.
    """

    return _DEFAULT_CONVERTER.to_geo(
        x=x,
        y=y,
        validate_bounds=validate_bounds,
    )
