from __future__ import annotations

from dataclasses import dataclass

import pytest

import moira.stars as stars
from moira.star_types import (
    DEFAULT_FIXED_STAR_POLICY,
    FixedStarComputationPolicy,
    HeliacalBatchResult,
    HeliacalEvent,
    HeliacalSearchPolicy,
)


@dataclass
class _FakeBody:
    longitude: float
    latitude: float = 0.0
    magnitude: float = 1.0


@pytest.fixture(autouse=True)
def _python_oracle_policy(monkeypatch: pytest.MonkeyPatch) -> None:
    """Keep mocked unit tests on the visible Python manuscript."""

    monkeypatch.setattr(
        stars,
        "DEFAULT_FIXED_STAR_POLICY",
        FixedStarComputationPolicy(use_native_heliacal=False),
    )


def test_heliacal_rising_event_returns_found_event(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr(
        "moira.planets.planet_at",
        lambda body, jd_ut, **kwargs: _FakeBody(
            85.0 if jd_ut < 2451545.0 else 114.5
        ),
    )
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", lambda *args, **kwargs: 2451545.25)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 7.5)

    event = stars.heliacal_rising_event("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=30)

    assert isinstance(event, HeliacalEvent)
    assert event.is_found is True
    assert event.jd_ut == pytest.approx(2451545.25)
    assert event.event_kind == "heliacal_rising"
    assert event.computation_truth is not None
    assert event.computation_truth.search_days == 30
    assert event.computation_truth.arcus_visionis == pytest.approx(10.0)
    assert event.classification is not None
    assert event.classification.visibility_state == "found"


def test_last_morning_visibility_event_returns_last_visible_morning(monkeypatch: pytest.MonkeyPatch) -> None:
    """The pre-6.9.9 ``heliacal_setting_event`` scan, preserved under its own name."""
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)

    def _fake_star_at(name: str, jd_tt: float, **_: object) -> _FakeBody:
        return _FakeBody(100.0, magnitude=1.2)

    def _fake_planet_at(body: str, jd_ut: float, **kwargs: object) -> _FakeBody:
        if jd_ut <= 2451546.0:
            return _FakeBody(114.5)
        return _FakeBody(104.0)

    monkeypatch.setattr(stars, "star_at", _fake_star_at)
    monkeypatch.setattr("moira.planets.planet_at", _fake_planet_at)
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", lambda jd_midnight, *args, **kwargs: jd_midnight + 0.25)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 6.0)

    event = stars.last_morning_visibility_event("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=30)

    assert event.is_found is True
    assert event.jd_ut == pytest.approx(2451545.75)
    assert event.event_kind == "last_morning_visibility"
    assert event.computation_truth is not None
    assert event.computation_truth.qualifying_day_offset == 1
    assert stars.last_morning_visibility(
        "Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=30
    ) == pytest.approx(2451545.75)


def test_heliacal_setting_event_returns_last_visible_evening(monkeypatch: pytest.MonkeyPatch) -> None:
    """Standard heliacal setting (6.9.9): last evening sighting east of the Sun."""
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)

    def _fake_planet_at(body: str, jd_ut: float, **kwargs: object) -> _FakeBody:
        # Star at 100 deg; the Sun 14.5 deg west of it, then 4 deg (in the glare).
        if jd_ut <= 2451546.0:
            return _FakeBody(85.5)
        return _FakeBody(96.0)

    evening_flags: list[bool] = []

    def _fake_sun_at_alt(jd_midnight, lat, lon, target_alt, morning):
        evening_flags.append(morning)
        return jd_midnight + 0.75

    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr("moira.planets.planet_at", _fake_planet_at)
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", _fake_sun_at_alt)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 6.0)

    event = stars.heliacal_setting_event("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=30)

    from moira.heliacal import _local_mean_solar_midnight

    assert event.is_found is True
    assert event.event_kind == "heliacal_setting"
    assert event.computation_truth is not None
    assert event.computation_truth.qualifying_elongation == pytest.approx(14.5)
    last_day = event.computation_truth.qualifying_day_offset
    assert event.jd_ut == pytest.approx(
        _local_mean_solar_midnight(2451545.0, 29.9) + last_day + 0.75
    )
    # Only dusk twilight is ever solved for.
    assert evening_flags and not any(evening_flags)


def test_heliacal_setting_event_ignores_morning_apparition(monkeypatch: pytest.MonkeyPatch) -> None:
    """A star west of the Sun (morning sky) never yields a heliacal setting."""
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)

    def _fake_planet_at(body: str, jd_ut: float, **kwargs: object) -> _FakeBody:
        if jd_ut <= 2451546.0:
            return _FakeBody(114.5)
        return _FakeBody(104.0)

    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr("moira.planets.planet_at", _fake_planet_at)
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", lambda jd_midnight, *args, **kwargs: jd_midnight + 0.75)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 6.0)

    event = stars.heliacal_setting_event("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=30)

    assert event.is_found is False
    assert event.jd_ut is None
    assert stars.heliacal_setting("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=30) is None


def test_heliacal_rising_returns_none_when_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=2.0))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(100.5))
    monkeypatch.setattr(stars, "_star_altitude", lambda name, jd_ut, lat, lon, **_: -30.0)
    monkeypatch.setattr(
        "moira.heliacal._find_sun_at_alt",
        lambda jd_midnight, *args, **kwargs: jd_midnight + 0.25,
    )

    assert stars.heliacal_rising("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=10.0, search_days=5) is None


def test_heliacal_event_rejects_invalid_arcus() -> None:
    with pytest.raises(ValueError, match="arcus_visionis must be a positive finite value"):
        stars.heliacal_rising_event("Sirius", 2451545.0, 31.2, 29.9, arcus_visionis=0.0)


def test_python_heliacal_rising_does_not_relabel_open_apparition_after_start(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    jd_start = 2451545.3
    monkeypatch.setattr(stars, "_heliacal_signed_elongation", lambda *args: -15.0)
    monkeypatch.setattr(
        "moira.heliacal._find_sun_at_alt",
        lambda jd_midnight, *args, **kwargs: jd_midnight + 0.25,
    )
    monkeypatch.setattr(stars, "_star_altitude", lambda *args, **kwargs: 6.0)

    event = stars.heliacal_rising_event(
        "Sirius",
        jd_start,
        31.2,
        29.9,
        arcus_visionis=10.0,
        search_days=3,
    )

    assert event.is_found is False
    assert event.jd_ut is None


@pytest.mark.parametrize(
    ("search", "side"),
    [
        (stars.heliacal_setting_event, 1.0),
        (stars.last_morning_visibility_event, -1.0),
    ],
)
def test_python_setting_searches_never_remember_visibility_before_forward_start(
    monkeypatch: pytest.MonkeyPatch,
    search,
    side: float,
) -> None:
    jd_start = 2451545.3

    def _signed_elongation(_name: str, jd_ut: float) -> float:
        return side * (15.0 if jd_ut < 2451546.0 else 4.0)

    monkeypatch.setattr(stars, "_heliacal_signed_elongation", _signed_elongation)
    monkeypatch.setattr(
        "moira.heliacal._find_sun_at_alt",
        lambda jd_midnight, *args, **kwargs: jd_midnight - 0.25,
    )
    monkeypatch.setattr(stars, "_star_altitude", lambda *args, **kwargs: 6.0)

    event = search(
        "Sirius",
        jd_start,
        31.2,
        29.9,
        arcus_visionis=10.0,
        search_days=4,
    )

    assert event.is_found is False
    assert event.jd_ut is None


@pytest.mark.parametrize(
    ("kwargs", "message"),
    [
        ({"setting_elongation_threshold": 0.0}, "setting_elongation_threshold"),
        ({"setting_elongation_threshold": float("nan")}, "setting_elongation_threshold"),
        ({"setting_visibility_factor": 0.0}, "setting_visibility_factor"),
        ({"setting_visibility_factor": 1.01}, "setting_visibility_factor"),
        ({"visibility_tolerance": 0.5}, "visibility_tolerance"),
    ],
)
def test_heliacal_policy_rejects_invalid_or_unsupported_states(
    kwargs: dict[str, float],
    message: str,
) -> None:
    with pytest.raises(ValueError, match=message):
        HeliacalSearchPolicy(**kwargs)


# ---------------------------------------------------------------------------
# heliacal_catalog_batch tests
# ---------------------------------------------------------------------------

def test_batch_returns_heliacal_batch_result_type(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(114.5))
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", lambda *args, **kwargs: 2451545.25)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 7.5)

    result = stars.heliacal_catalog_batch(
        "heliacal_rising", 2451545.0, 31.2, 29.9,
        names=["Sirius", "Vega"], max_magnitude=7.0, search_days=30,
    )
    assert isinstance(result, HeliacalBatchResult)


def test_native_heliacal_default_and_python_oracle_opt_out(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    assert DEFAULT_FIXED_STAR_POLICY.use_native_heliacal is True
    assert FixedStarComputationPolicy(use_native_heliacal=True).use_native_heliacal is True
    assert (
        FixedStarComputationPolicy(
            heliacal=HeliacalSearchPolicy(setting_elongation_threshold=20.0),
            use_native_heliacal=True,
        ).heliacal
        != HeliacalSearchPolicy()
    )
    monkeypatch.setattr(
        stars.mn,
        "search_heliacal_rising",
        lambda *args, **kwargs: pytest.fail("explicit Python oracle must not enter native search"),
    )
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(
        stars,
        "star_at",
        lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2),
    )
    monkeypatch.setattr(
        "moira.planets.planet_at",
        lambda body, jd_ut, **kwargs: _FakeBody(114.5),
    )
    monkeypatch.setattr(
        "moira.heliacal._find_sun_at_alt",
        lambda *args, **kwargs: 2451545.25,
    )
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 7.5)

    result = stars.heliacal_catalog_batch(
        "heliacal_rising",
        2451545.0,
        31.2,
        29.9,
        names=["Sirius"],
        max_magnitude=2.0,
        search_days=5,
        policy=FixedStarComputationPolicy(
            heliacal=HeliacalSearchPolicy(setting_elongation_threshold=20.0),
            use_native_heliacal=False,
        ),
    )
    assert result.total_catalog == 1


def test_native_heliacal_worker_policy_is_bounded() -> None:
    with pytest.raises(ValueError, match="native_heliacal_workers"):
        FixedStarComputationPolicy(native_heliacal_workers=0)
    with pytest.raises(ValueError, match="native_heliacal_workers"):
        FixedStarComputationPolicy(native_heliacal_workers=65)


def test_batch_found_contains_heliacal_events(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(114.5))
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", lambda *args, **kwargs: 2451545.25)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 7.5)

    result = stars.heliacal_catalog_batch(
        "heliacal_rising", 2451545.0, 31.2, 29.9,
        names=["Sirius", "Vega"], max_magnitude=7.0, search_days=30,
    )
    assert all(isinstance(e, HeliacalEvent) for e in result.found)
    assert all(e.is_found for e in result.found)


def test_batch_skips_faint_stars(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=9.0))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(114.5))


def test_star_heliacal_signed_elongation_passes_tt_explicitly_without_double_conversion(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    captured: dict[str, float] = {}

    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd + 0.123)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(120.0, magnitude=1.0))

    def _fake_planet_at(body: str, jd_ut: float, **kwargs: object) -> _FakeBody:
        captured["jd_ut"] = jd_ut
        captured["jd_tt"] = float(kwargs["jd_tt"])
        return _FakeBody(100.0)

    monkeypatch.setattr("moira.planets.planet_at", _fake_planet_at)

    elong = stars._heliacal_signed_elongation("Sirius", 2451545.0)

    assert elong == pytest.approx(20.0)
    assert captured["jd_ut"] == pytest.approx(2451545.0)
    assert captured["jd_tt"] == pytest.approx(2451545.123)

    result = stars.heliacal_catalog_batch(
        "heliacal_rising", 2451545.0, 31.2, 29.9,
        names=["Sirius"],  # Sirius V=−1.46 in registry; mock doesn't matter for pre-filter
        max_magnitude=2.0,
        search_days=5,
    )
    # Sirius has V ≈ -1.46 in the registry so it passes; this tests the pathway exists
    assert isinstance(result, HeliacalBatchResult)
    assert result.total_catalog == 1


def test_batch_skips_out_of_lat_limit(monkeypatch: pytest.MonkeyPatch) -> None:
    # Proxima Centauri dec ≈ -62.7° → lat_limit ≈ 27.3°
    # Observer at 60°N cannot see it.
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.0))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(114.5))

    result = stars.heliacal_catalog_batch(
        "heliacal_rising", 2451545.0, 60.0, 24.0,
        names=["Proxima Centauri"], max_magnitude=12.0, search_days=5,
    )
    assert "Proxima Centauri" in result.skipped_latitude
    assert result.total_searched == 0


def test_batch_total_catalog_property(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(114.5))
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", lambda *args, **kwargs: 2451545.25)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 7.5)

    result = stars.heliacal_catalog_batch(
        "heliacal_rising", 2451545.0, 31.2, 29.9,
        names=["Sirius", "Vega", "Proxima Centauri"],
        max_magnitude=7.0, search_days=30,
    )
    assert result.total_catalog == 3
    assert result.total_catalog == result.total_searched + result.total_skipped


def test_batch_found_sorted_by_jd_ut(monkeypatch: pytest.MonkeyPatch) -> None:
    call_counter = {"n": 0}

    def _fake_sun_at_alt(*args: object, **kwargs: object) -> float:
        call_counter["n"] += 1
        # Return decreasing JD to verify re-sort ascending
        return 2451545.0 + (10 - call_counter["n"])

    monkeypatch.setattr("moira.julian.ut_to_tt", lambda jd: jd)
    monkeypatch.setattr(stars, "star_at", lambda name, jd_tt, **_: _FakeBody(100.0, magnitude=1.2))
    monkeypatch.setattr("moira.planets.planet_at", lambda body, jd_ut, **kwargs: _FakeBody(114.5))
    monkeypatch.setattr("moira.heliacal._find_sun_at_alt", _fake_sun_at_alt)
    monkeypatch.setattr("moira.rise_set._altitude", lambda *args, **kwargs: 7.5)

    result = stars.heliacal_catalog_batch(
        "heliacal_rising", 2451545.0, 31.2, 29.9,
        names=["Sirius", "Vega"], max_magnitude=7.0, search_days=30,
    )
    jd_values = [e.jd_ut for e in result.found if e.jd_ut is not None]
    assert jd_values == sorted(jd_values)


def test_batch_rejects_invalid_event_kind() -> None:
    with pytest.raises(ValueError, match="event_kind must be"):
        stars.heliacal_catalog_batch("cosmic_rising", 2451545.0, 31.2, 29.9)


def test_batch_heliacal_setting_never_enters_morning_native_kernel(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The compiled setting kernel scans mornings; it must not serve the
    standard (evening last) heliacal setting even when native is admitted."""
    monkeypatch.setattr(
        stars.mn,
        "search_heliacal_setting",
        lambda *args, **kwargs: pytest.fail("evening-last search must not use the morning kernel"),
    )
    monkeypatch.setattr(
        stars.mn,
        "search_heliacal_rising",
        lambda *args, **kwargs: pytest.fail("evening-last search must not use the rising kernel"),
    )
    called: list[str] = []
    monkeypatch.setitem(
        stars._HELIACAL_BATCH_SEARCHES,
        "heliacal_setting",
        lambda name, *args, **kwargs: called.append(name) or stars._build_heliacal_event(
            "heliacal_setting", name, 2451545.0, 5, 10.0, 12.0, None, None, None, None
        ),
    )

    result = stars.heliacal_catalog_batch(
        "heliacal_setting",
        2451545.0,
        31.2,
        29.9,
        names=["Sirius"],
        max_magnitude=2.0,
        search_days=5,
        policy=FixedStarComputationPolicy(use_native_heliacal=True),
    )
    assert called == ["Sirius"]
    assert result.event_kind == "heliacal_setting"
    assert result.not_found == ("Sirius",)


def test_batch_rejects_unknown_names() -> None:
    with pytest.raises(ValueError, match="Unknown star names"):
        stars.heliacal_catalog_batch(
            "heliacal_rising", 2451545.0, 31.2, 29.9, names=["NotAStarXYZ"]
        )
