"""Phase 3F tests — Reach tube operations.

Tests cut, projection, intersect, evolve, refine, accessors, display.
"""

import numpy as np
import pytest

from ellipsoidal_toolbox.core.config import ABS_TOL
from ellipsoidal_toolbox.core.ellipsoid import Ellipsoid
from ellipsoidal_toolbox.core.hyperplane import Hyperplane
from ellipsoidal_toolbox.systems.linsys import LinSys
from ellipsoidal_toolbox.systems.reach import (
    ReachSet, reach_ea, reach_ia, reach_ea_d, reach_ia_d,
)


# ── Fixture helpers ──────────────────────────────────────────────────

def _make_ea_rs() -> ReachSet:
    """Stable 2D continuous EA reach set."""
    A = -np.eye(2)
    B = np.eye(2)
    U = Ellipsoid(np.zeros(2), np.eye(2))
    sys = LinSys(A, B, U)
    X0 = Ellipsoid(np.zeros(2), np.eye(2))
    L0 = np.array([[1, 0], [0, 1]], dtype=float)
    return reach_ea(sys, X0, L0, (0, 2), N_steps=21)


def _make_ia_rs() -> ReachSet:
    """Stable 2D continuous IA reach set."""
    A = -np.eye(2)
    B = np.eye(2)
    U = Ellipsoid(np.zeros(2), np.eye(2))
    sys = LinSys(A, B, U)
    X0 = Ellipsoid(np.zeros(2), np.eye(2))
    L0 = np.array([[1, 0], [0, 1]], dtype=float)
    return reach_ia(sys, X0, L0, (0, 2), N_steps=21)


def _make_discrete_ea_rs() -> ReachSet:
    """Stable 2D discrete EA reach set."""
    A = 0.9 * np.eye(2)
    B = np.eye(2)
    U = Ellipsoid(np.zeros(2), np.eye(2))
    sys = LinSys(A, B, U, discrete=True)
    X0 = Ellipsoid(np.zeros(2), np.eye(2))
    L0 = np.array([[1, 0], [0, 1]], dtype=float)
    return reach_ea_d(sys, X0, L0, N_steps=10)


# =====================================================================
# Accessors
# =====================================================================

class TestAccessors:
    def test_get_ea(self):
        rs = _make_ea_rs()
        ea = rs.get_ea()
        assert len(ea) == 21
        assert len(ea[0]) == 2
        assert isinstance(ea[0][0], Ellipsoid)

    def test_get_ia(self):
        rs = _make_ia_rs()
        ia = rs.get_ia()
        assert len(ia) == 21
        assert isinstance(ia[5][1], Ellipsoid)

    def test_get_ea_no_data_raises(self):
        rs = _make_ia_rs()
        with pytest.raises(ValueError, match="No external"):
            rs.get_ea()

    def test_get_ia_no_data_raises(self):
        rs = _make_ea_rs()
        with pytest.raises(ValueError, match="No internal"):
            rs.get_ia()

    def test_get_center(self):
        rs = _make_ea_rs()
        c = rs.get_center()
        assert c.shape == (21, 2)
        c[0] = [999, 999]
        assert rs.centers[0, 0] != 999  # returns a copy

    def test_get_directions(self):
        rs = _make_ea_rs()
        L = rs.get_directions()
        assert L is not None
        assert L.shape == (2, 2)

    def test_get_directions_none(self):
        rs = ReachSet()
        assert rs.get_directions() is None

    def test_get_system(self):
        rs = _make_ea_rs()
        sys = rs.get_system()
        assert isinstance(sys, LinSys)

    def test_dimension(self):
        rs = _make_ea_rs()
        assert rs.dimension() == 2

    def test_iscut_false(self):
        rs = _make_ea_rs()
        assert not rs.iscut()

    def test_isprojection_false(self):
        rs = _make_ea_rs()
        assert not rs.isprojection()


# =====================================================================
# cut
# =====================================================================

class TestCut:
    def test_cut_single_time(self):
        rs = _make_ea_rs()
        c = rs.cut(1.0)
        assert len(c.t_eval) == 1
        assert abs(c.t_eval[0] - 1.0) < 0.15
        assert c.ea_shapes.shape[0] == 1
        assert c.iscut()

    def test_cut_interval(self):
        rs = _make_ea_rs()
        c = rs.cut((0.5, 1.5))
        assert len(c.t_eval) > 1
        assert c.t_eval[0] >= 0.5 - ABS_TOL
        assert c.t_eval[-1] <= 1.5 + ABS_TOL
        assert c.iscut()
        assert c.n_dirs == 2
        assert c.n == 2

    def test_cut_full_range(self):
        rs = _make_ea_rs()
        c = rs.cut((0.0, 2.0))
        assert len(c.t_eval) == len(rs.t_eval)

    def test_cut_preserves_shapes(self):
        rs = _make_ea_rs()
        idx = 10  # mid-point
        t_target = rs.t_eval[idx]
        c = rs.cut(t_target)
        np.testing.assert_allclose(c.ea_shapes[0, 0], rs.ea_shapes[idx, 0],
                                    atol=1e-12)

    def test_cut_empty_interval_raises(self):
        rs = _make_ea_rs()
        with pytest.raises(ValueError, match="no time points"):
            rs.cut((10.0, 20.0))

    def test_cut_ia(self):
        rs = _make_ia_rs()
        c = rs.cut(1.0)
        assert c.ia_shapes is not None
        assert c.ea_shapes is None

    def test_cut_preserves_metadata(self):
        rs = _make_ea_rs()
        c = rs.cut(1.0)
        assert c.sys is rs.sys
        assert c.X0 is rs.X0


# =====================================================================
# projection
# =====================================================================

class TestProjection:
    def test_project_3d_to_2d(self):
        A = -np.eye(3)
        B = np.eye(3)
        U = Ellipsoid(np.zeros(3), np.eye(3))
        sys = LinSys(A, B, U)
        X0 = Ellipsoid(np.zeros(3), np.eye(3))
        L0 = np.eye(3)
        rs = reach_ea(sys, X0, L0, (0, 1), N_steps=10)

        basis = np.array([[1, 0], [0, 1], [0, 0]], dtype=float)
        p = rs.projection(basis)

        assert p.n == 2
        assert p.ea_shapes.shape == (10, 3, 2, 2)
        assert p.centers.shape == (10, 2)
        assert p.isprojection()

    def test_project_identity_preserves(self):
        rs = _make_ea_rs()
        basis = np.eye(2)
        p = rs.projection(basis)
        np.testing.assert_allclose(p.ea_shapes, rs.ea_shapes, atol=1e-12)
        np.testing.assert_allclose(p.centers, rs.centers, atol=1e-12)

    def test_project_to_1d(self):
        rs = _make_ea_rs()
        basis = np.array([[1.0], [0.0]])
        p = rs.projection(basis)
        assert p.n == 1
        assert p.ea_shapes.shape[2:] == (1, 1)

    def test_project_bad_dimension_raises(self):
        rs = _make_ea_rs()
        bad_basis = np.eye(3)
        with pytest.raises(ValueError, match="basis rows"):
            rs.projection(bad_basis)

    def test_project_ia(self):
        rs = _make_ia_rs()
        basis = np.eye(2)
        p = rs.projection(basis)
        assert p.ia_shapes is not None
        assert p.ea_shapes is None

    def test_projected_shapes_are_psd(self):
        A = -np.eye(3)
        B = np.eye(3)
        U = Ellipsoid(np.zeros(3), np.eye(3))
        sys = LinSys(A, B, U)
        X0 = Ellipsoid(np.zeros(3), np.eye(3))
        L0 = np.eye(3)
        rs = reach_ea(sys, X0, L0, (0, 1), N_steps=10)
        basis = np.array([[1, 0], [0, 1], [0, 0]], dtype=float)
        p = rs.projection(basis)

        for k in range(10):
            for j in range(3):
                eigvals = np.linalg.eigvalsh(p.ea_shapes[k, j])
                assert np.min(eigvals) >= -ABS_TOL


# =====================================================================
# intersect
# =====================================================================

class TestIntersect:
    def test_intersect_with_overlapping_ellipsoid(self):
        rs = _make_ea_rs()
        X = Ellipsoid(np.zeros(2), 0.1 * np.eye(2))
        hit, k = rs.intersect(X)
        assert hit
        assert k == 0  # overlaps at t=0

    def test_intersect_with_distant_ellipsoid(self):
        rs = _make_ea_rs()
        X = Ellipsoid([100.0, 100.0], 0.01 * np.eye(2))
        hit, k = rs.intersect(X)
        assert not hit
        assert k is None

    def test_intersect_with_hyperplane_through_center(self):
        rs = _make_ea_rs()
        H = Hyperplane([1.0, 0.0], 0.0)
        hit, k = rs.intersect(H)
        assert hit
        assert k is not None

    def test_intersect_with_distant_hyperplane(self):
        rs = _make_ea_rs()
        H = Hyperplane([1.0, 0.0], 1000.0)
        hit, k = rs.intersect(H)
        assert not hit

    def test_intersect_no_data_raises(self):
        rs = ReachSet(t_eval=np.array([0.0]), centers=np.array([[0, 0]]),
                       n_dirs=0, n=2)
        with pytest.raises(ValueError, match="no approximation"):
            rs.intersect(Ellipsoid(np.zeros(2), np.eye(2)))


# =====================================================================
# evolve
# =====================================================================

class TestEvolve:
    def test_evolve_extends_time(self):
        rs = _make_discrete_ea_rs()
        assert rs.t_eval[-1] == 10.0
        rs2 = rs.evolve(15.0)
        assert rs2.t_eval[-1] >= 14.5
        assert len(rs2.t_eval) > len(rs.t_eval)

    def test_evolve_preserves_original(self):
        rs = _make_discrete_ea_rs()
        N_orig = len(rs.t_eval)
        rs2 = rs.evolve(15.0)
        np.testing.assert_allclose(rs2.t_eval[:N_orig], rs.t_eval, atol=1e-12)
        np.testing.assert_allclose(rs2.ea_shapes[:N_orig], rs.ea_shapes,
                                    atol=1e-12)

    def test_evolve_no_metadata_raises(self):
        rs = ReachSet()
        with pytest.raises(ValueError, match="metadata"):
            rs.evolve(5.0)

    def test_evolve_same_time_returns_self(self):
        rs = _make_discrete_ea_rs()
        rs2 = rs.evolve(rs.t_eval[-1])
        assert rs2 is rs

    def test_evolve_continuous(self):
        rs = _make_ea_rs()
        assert rs.t_eval[-1] == 2.0
        rs2 = rs.evolve(3.0, N_extra=20)
        assert rs2.t_eval[-1] >= 2.9
        assert len(rs2.t_eval) > len(rs.t_eval)


# =====================================================================
# refine
# =====================================================================

class TestRefine:
    def test_refine_adds_directions(self):
        rs = _make_ea_rs()
        assert rs.n_dirs == 2
        L_new = np.array([[1.0], [1.0]]) / np.sqrt(2)
        rs2 = rs.refine(L_new)
        assert rs2.n_dirs == 3
        assert rs2.ea_shapes.shape[1] == 3

    def test_refine_preserves_original_directions(self):
        rs = _make_ea_rs()
        L_new = np.array([[1.0], [1.0]]) / np.sqrt(2)
        rs2 = rs.refine(L_new)
        np.testing.assert_allclose(rs2.ea_shapes[:, :2, :, :],
                                    rs.ea_shapes, atol=1e-6)

    def test_refine_no_metadata_raises(self):
        rs = ReachSet()
        with pytest.raises(ValueError, match="metadata"):
            rs.refine(np.eye(2))

    def test_refine_discrete(self):
        rs = _make_discrete_ea_rs()
        L_new = np.array([[1.0], [1.0]]) / np.sqrt(2)
        rs2 = rs.refine(L_new)
        assert rs2.n_dirs == 3
        assert rs2.ea_shapes.shape[0] == rs.ea_shapes.shape[0]

    def test_refine_L0_merged(self):
        rs = _make_ea_rs()
        L_new = np.array([[1.0], [1.0]]) / np.sqrt(2)
        rs2 = rs.refine(L_new)
        L = rs2.get_directions()
        assert L is not None
        assert L.shape == (2, 3)


# =====================================================================
# display
# =====================================================================

class TestDisplay:
    def test_repr(self):
        rs = _make_ea_rs()
        r = repr(rs)
        assert "ReachSet" in r
        assert "n=2" in r
        assert "EA" in r

    def test_repr_ia(self):
        rs = _make_ia_rs()
        r = repr(rs)
        assert "IA" in r
        assert "EA" not in r

    def test_repr_cut(self):
        rs = _make_ea_rs()
        c = rs.cut(1.0)
        r = repr(c)
        assert "cut" in r

    def test_repr_projection(self):
        rs = _make_ea_rs()
        p = rs.projection(np.eye(2))
        r = repr(p)
        assert "proj" in r

    def test_str(self):
        rs = _make_ea_rs()
        s = str(rs)
        assert "Reach Set" in s
        assert "dimension 2" in s
        assert "EA computed: True" in s
        assert "IA computed: False" in s

    def test_str_empty(self):
        rs = ReachSet()
        s = str(rs)
        assert "Reach Set" in s
