from __future__ import annotations

from dataclasses import dataclass, asdict, field
from datetime import date, datetime, timedelta
import math
import json
from pathlib import Path

import pytz
from skyfield.api import load, wgs84, load_constellation_map
from skyfield import almanac

TZ = pytz.timezone("Europe/Paris")
SCRIPT_DIR = Path(__file__).resolve().parent
LATITUDE = 48.8566
LONGITUDE = 2.3522

# --- Mapping "jour racine/feuille/fleur/fruit" ---
# We align with a sidereal approach using the Moon's constellation.
CONSTELLATION_TO_SIGN = {
    "Ari": "Bélier",
    "Tau": "Taureau",
    "Gem": "Gémeaux",
    "Cnc": "Cancer",
    "Leo": "Lion",
    "Vir": "Vierge",
    "Lib": "Balance",
    "Sco": "Scorpion",
    "Sgr": "Sagittaire",
    "Cap": "Capricorne",
    "Aqr": "Verseau",
    "Psc": "Poissons",
    # The Moon can cross Ophiuchus in official IAU boundaries.
    # We map it to Scorpion for gardening day continuity.
    "Oph": "Scorpion",
}

SIGN_TO_TYPE = {
    "Taureau": "Racine", "Vierge": "Racine", "Capricorne": "Racine",
    "Cancer": "Feuille", "Scorpion": "Feuille", "Poissons": "Feuille",
    "Gémeaux": "Fleur", "Balance": "Fleur", "Verseau": "Fleur",
    "Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
}


@dataclass
class DayInfo:
    date: str
    phase: str
    illumination: float
    croissante_decroissante: str
    montante_descendante: str
    signe: str
    type_jour: str
    soleil_lever: str
    soleil_coucher: str
    duree_jour: str
    lune_lever: str
    lune_coucher: str
    duree_presence_lune: str
    saint_du_jour: str
    saint_de_glace: bool
    perigee: bool
    apogee: bool
    noeud_lunaire: bool
    transitions_type_jour: list[dict[str, str]] = field(default_factory=list)
    transitions_montante_descendante: list[dict[str, str]] = field(default_factory=list)


def _zodiac_sign_from_constellation(constellation_at, position) -> str:
    abbr = constellation_at(position)
    return CONSTELLATION_TO_SIGN.get(abbr, "Scorpion")


def _local_noon(d: date) -> datetime:
    return TZ.localize(datetime(d.year, d.month, d.day, 12, 0, 0))


def _default_saints_france() -> dict[str, str]:
    # Core gardening references in France; full calendar can be provided via saints_france.json.
    return {
        "04-23": "Saint Georges",
        "04-25": "Saint Marc",
        "05-11": "Saint Mamert",
        "05-12": "Saint Pancrace",
        "05-13": "Saint Servais",
        "05-14": "Saint Boniface",
        "05-19": "Saint Yves",
        "05-25": "Saint Urbain",
    }


def _load_saints_france() -> dict[str, str]:
    path = SCRIPT_DIR / "saints_dictons" / "saints_france.json"
    if not path.exists():
        return _default_saints_france()

    with path.open("r", encoding="utf-8") as f:
        data = json.load(f)

    saints: dict[str, str] = {}
    for key, value in data.items():
        if isinstance(key, str) and isinstance(value, str):
            saints[key] = value.strip()
    return saints


def _compute_perigee_apogee_days(ts, earth, moon, start: date, end: date) -> tuple[set[date], set[date]]:
    # Hourly sampling + one-day padding on each side gives stable local extrema detection.
    sample_start = datetime.combine(start - timedelta(days=1), datetime.min.time())
    sample_end = datetime.combine(end + timedelta(days=1), datetime.max.time().replace(microsecond=0))

    samples: list[tuple[date, float]] = []
    current = TZ.localize(sample_start)
    end_local = TZ.localize(sample_end)
    step = timedelta(hours=1)

    while current <= end_local:
        t = ts.utc(current.astimezone(pytz.utc))
        dist_km = earth.at(t).observe(moon).distance().km
        samples.append((current.date(), dist_km))
        current += step

    perigee_days: set[date] = set()
    apogee_days: set[date] = set()

    for i in range(1, len(samples) - 1):
        day, dist = samples[i]
        if not (start <= day <= end):
            continue

        prev_dist = samples[i - 1][1]
        next_dist = samples[i + 1][1]
        if dist < prev_dist and dist < next_dist:
            perigee_days.add(day)
        if dist > prev_dist and dist > next_dist:
            apogee_days.add(day)

    return perigee_days, apogee_days


def _to_local_dt(t) -> datetime:
    return t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ)


def _pick_first_event_within_window(
    ts,
    observer,
    target,
    start_local: datetime,
    end_local: datetime,
    event_kind: str,
) -> tuple[datetime | None, int | None]:
    if event_kind == "rise":
        event_func = almanac.find_risings
    else:
        event_func = almanac.find_settings

    t0 = ts.utc(start_local.astimezone(pytz.utc))
    t1 = ts.utc(end_local.astimezone(pytz.utc))
    times, flags = event_func(observer, target, t0, t1)

    for t, ok in zip(times, flags):
        if not ok:
            continue
        dt_local = _to_local_dt(t)
        if start_local <= dt_local < end_local:
            day_offset = (dt_local.date() - start_local.date()).days
            return dt_local, day_offset

    return None, None


def _format_time(dt_local: datetime | None, day_offset: int | None) -> str:
    if dt_local is None:
        return ""
    base = dt_local.strftime("%H:%M")
    if day_offset and day_offset > 0:
        return f"{base} (+{day_offset}j)"
    return base


def _format_duration(start_dt: datetime | None, end_dt: datetime | None) -> str:
    if start_dt is None or end_dt is None:
        return ""
    delta = end_dt - start_dt
    if delta.total_seconds() < 0:
        return ""
    total_minutes = int(round(delta.total_seconds() / 60))
    hours, minutes = divmod(total_minutes, 60)
    return f"{hours:02d}h{minutes:02d}"


def _moon_type_jour_at(ts, earth, moon, constellation_at, local_dt: datetime) -> str:
    t = ts.utc(local_dt.astimezone(pytz.utc))
    v_moon = earth.at(t).observe(moon).apparent()
    signe = _zodiac_sign_from_constellation(constellation_at, v_moon)
    return SIGN_TO_TYPE[signe]


def _moon_montante_descendante_at(ts, earth, moon, local_dt: datetime) -> str:
    t = ts.utc(local_dt.astimezone(pytz.utc))
    t2 = ts.utc((local_dt + timedelta(minutes=30)).astimezone(pytz.utc))
    v_moon = earth.at(t).observe(moon).apparent()
    v_moon2 = earth.at(t2).observe(moon).apparent()
    dec = v_moon.radec()[1].degrees
    dec2 = v_moon2.radec()[1].degrees
    return "Montante" if dec2 >= dec else "Descendante"


def _find_transition_time(
    value_at,
    left_dt: datetime,
    right_dt: datetime,
    left_value: str,
) -> datetime:
    # Binary search at minute precision for the first instant where value changes.
    while (right_dt - left_dt) > timedelta(minutes=1):
        mid = left_dt + (right_dt - left_dt) / 2
        if value_at(mid) == left_value:
            left_dt = mid
        else:
            right_dt = mid
    return right_dt.replace(second=0, microsecond=0)


def _compute_daily_transitions(
    value_at,
    day_start: datetime,
    day_end: datetime,
    step_minutes: int = 20,
) -> list[dict[str, str]]:
    transitions: list[dict[str, str]] = []
    step = timedelta(minutes=step_minutes)

    t = day_start
    current_value = value_at(t)

    while t < day_end:
        probe = min(t + step, day_end)
        probe_value = value_at(probe)
        if probe_value != current_value:
            transition_dt = _find_transition_time(value_at, t, probe, current_value)
            transitions.append(
                {
                    "heure": transition_dt.strftime("%H:%M"),
                    "avant": current_value,
                    "apres": probe_value,
                }
            )
            current_value = probe_value
        t = probe

    return transitions


def build_calendar(start: date, end: date) -> list[DayInfo]:
    if end < start:
        raise ValueError(f"Invalid date range: start ({start}) is after end ({end}).")

    ts = load.timescale()
    eph = load("de421.bsp")
    constellation_at = load_constellation_map()
    saints_by_mmdd = _load_saints_france()
    saints_de_glace = {"05-11", "05-12", "05-13", "05-14", "05-25"}

    earth, moon, sun = eph["earth"], eph["moon"], eph["sun"]
    observer = earth + wgs84.latlon(LATITUDE, LONGITUDE)

    t0 = ts.utc(start.year, start.month, start.day)
    t1 = ts.utc(end.year, end.month, end.day + 1)

    # --- Phases exactes ---
    f_phase = almanac.moon_phases(eph)
    phase_times, phase_events = almanac.find_discrete(t0, t1, f_phase)

    phase_by_day: dict[date, str] = {}
    for t, ev in zip(phase_times, phase_events):
        local_day = t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
        phase_by_day[local_day] = ["Nouvelle Lune", "Premier Quartier",
                                   "Pleine Lune", "Dernier Quartier"][int(ev)]

    # --- Nœuds lunaires (instants) ---
    f_nodes = almanac.moon_nodes(eph)
    node_times, _ = almanac.find_discrete(t0, t1, f_nodes)

    node_days: set[date] = set()
    for t in node_times:
        local_day = t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
        node_days.add(local_day)

    # --- Périgée / apogée : calcul manuel via distance Terre->Lune (min/max locaux) ---
    perigee_days, apogee_days = _compute_perigee_apogee_days(ts, earth, moon, start, end)

    # --- Boucle jour par jour ---
    result: list[DayInfo] = []
    d = start

    while d <= end:
        # midi local : stabilise signe du jour + évite bascules UTC
        local_noon = _local_noon(d)
        local_day_start = TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
        local_day_end = local_day_start + timedelta(days=1)
        local_moon_window_end = local_day_start + timedelta(days=2)
        t = ts.utc(local_noon.astimezone(pytz.utc))

        e = earth.at(t)
        v_sun = e.observe(sun).apparent()
        v_moon = e.observe(moon).apparent()

        # illumination (0..1) via séparation soleil-lune
        sep = v_sun.separation_from(v_moon).radians
        illum = (1 - math.cos(sep)) / 2

        # lendemain (pour croissante/décroissante + montante/descendante)
        d2 = d + timedelta(days=1)
        local_noon2 = _local_noon(d2)
        t2 = ts.utc(local_noon2.astimezone(pytz.utc))

        e2 = earth.at(t2)
        v_sun2 = e2.observe(sun).apparent()
        v_moon2 = e2.observe(moon).apparent()

        sep2 = v_sun2.separation_from(v_moon2).radians
        illum2 = (1 - math.cos(sep2)) / 2

        croissante = "Croissante" if illum2 >= illum else "Décroissante"

        dec = v_moon.radec()[1].degrees
        dec2 = v_moon2.radec()[1].degrees
        montante = "Montante" if dec2 >= dec else "Descendante"

        # sidereal sign via Moon constellation
        signe = _zodiac_sign_from_constellation(constellation_at, v_moon)
        type_jour = SIGN_TO_TYPE[signe]
        mmdd = f"{d.month:02d}-{d.day:02d}"

        sun_rise_dt, sun_rise_offset = _pick_first_event_within_window(
            ts, observer, sun, local_day_start, local_day_end, "rise"
        )
        sun_set_dt, sun_set_offset = _pick_first_event_within_window(
            ts, observer, sun, local_day_start, local_day_end, "set"
        )
        moon_rise_dt, moon_rise_offset = _pick_first_event_within_window(
            ts, observer, moon, local_day_start, local_moon_window_end, "rise"
        )
        moon_set_dt, moon_set_offset = _pick_first_event_within_window(
            ts, observer, moon, local_day_start, local_moon_window_end, "set"
        )
        transitions_type_jour = _compute_daily_transitions(
            lambda dt: _moon_type_jour_at(ts, earth, moon, constellation_at, dt),
            local_day_start,
            local_day_end,
        )
        transitions_montante_descendante = _compute_daily_transitions(
            lambda dt: _moon_montante_descendante_at(ts, earth, moon, dt),
            local_day_start,
            local_day_end,
        )

        result.append(DayInfo(
            date=d.isoformat(),
            phase=phase_by_day.get(d, ""),
            illumination=round(illum * 100.0, 2),  # %
            croissante_decroissante=croissante,
            montante_descendante=montante,
            signe=signe,
            type_jour=type_jour,
            soleil_lever=_format_time(sun_rise_dt, sun_rise_offset),
            soleil_coucher=_format_time(sun_set_dt, sun_set_offset),
            duree_jour=_format_duration(sun_rise_dt, sun_set_dt),
            lune_lever=_format_time(moon_rise_dt, moon_rise_offset),
            lune_coucher=_format_time(moon_set_dt, moon_set_offset),
            duree_presence_lune=_format_duration(moon_rise_dt, moon_set_dt),
            transitions_type_jour=transitions_type_jour,
            transitions_montante_descendante=transitions_montante_descendante,
            saint_du_jour=saints_by_mmdd.get(mmdd, ""),
            saint_de_glace=(mmdd in saints_de_glace),
            perigee=(d in perigee_days),
            apogee=(d in apogee_days),
            noeud_lunaire=(d in node_days),
        ))

        d += timedelta(days=1)

    return result


if __name__ == "__main__":
    data = build_calendar(date(2026, 1, 1), date(2026, 12, 31))
    out_path = Path(__file__).with_name("calendrier_lunaire_2026.json")

    with out_path.open("w", encoding="utf-8") as f:
        json.dump([asdict(x) for x in data], f, ensure_ascii=False, indent=2)

    print(f"Calendrier lunaire généré : {out_path}")
