"""Tests for BIE kernel functions."""

import numpy as np
import pytest

from panel_bie.kernels import (
    laplace_single_layer_kernel,
    laplace_double_layer_kernel,
    laplace_double_layer_diagonal,
    laplace_single_layer_matrix,
    laplace_double_layer_matrix,
)


class TestSingleLayerKernel:
    """Tests for single-layer kernel evaluation."""

    def test_kernel_logarithmic(self):
        """Test kernel is (1/2π) log|x-y|."""
        x = np.array([2.0, 0.0])
        y = np.array([0.0, 0.0])

        k = laplace_single_layer_kernel(x, y)

        # G(x,y) = log(|x-y|) / 2π = log(2) / 2π
        expected = np.log(2) / (2 * np.pi)
        assert k == pytest.approx(expected)

    def test_kernel_symmetric(self):
        """G(x,y) = G(y,x) by symmetry."""
        x = np.array([1.5, 0.7])
        y = np.array([-0.3, 2.1])

        k1 = laplace_single_layer_kernel(x, y)
        k2 = laplace_single_layer_kernel(y, x)

        assert k1 == pytest.approx(k2)

    def test_kernel_at_same_point(self):
        """Kernel returns 0 for x == y (caller handles diagonal)."""
        x = np.array([1.0, 2.0])
        k = laplace_single_layer_kernel(x, x)
        assert k == 0.0


class TestDoubleLayerKernel:
    """Tests for double-layer kernel evaluation."""

    def test_kernel_basic_value(self):
        """Test kernel at a specific point."""
        x = np.array([1.0, 0.0])
        y = np.array([0.0, 1.0])
        n_y = np.array([0.0, 1.0])  # Normal pointing in +y

        k = laplace_double_layer_kernel(x, y, n_y)

        # K(x,y) = (1/2π) * (x-y)·n_y / |x-y|²
        diff = x - y  # [1, -1]
        r_sq = 2.0
        expected = (1 / (2 * np.pi)) * np.dot(diff, n_y) / r_sq
        # dot = 1*0 + (-1)*1 = -1
        expected = -1 / (2 * np.pi * 2)

        assert k == pytest.approx(expected)

    def test_kernel_zero_when_perpendicular(self):
        """Kernel is zero when (x-y) perpendicular to normal."""
        x = np.array([1.0, 0.0])
        y = np.array([0.0, 0.0])
        n_y = np.array([0.0, 1.0])  # Perpendicular to x-y

        k = laplace_double_layer_kernel(x, y, n_y)
        assert k == pytest.approx(0.0)

    def test_kernel_symmetry(self):
        """Test kernel behavior under reflection."""
        x = np.array([2.0, 1.0])
        y = np.array([1.0, 0.0])
        n_y = np.array([1.0, 0.0])

        k1 = laplace_double_layer_kernel(x, y, n_y)

        # Reflect across x-axis
        x_ref = np.array([2.0, -1.0])
        y_ref = np.array([1.0, 0.0])
        n_y_ref = np.array([1.0, 0.0])

        k2 = laplace_double_layer_kernel(x_ref, y_ref, n_y_ref)

        # Should be equal by symmetry
        assert k1 == pytest.approx(k2)

    def test_kernel_at_same_point(self):
        """Kernel returns 0 for x == y (caller handles diagonal)."""
        x = np.array([1.0, 2.0])
        n = np.array([1.0, 0.0])
        k = laplace_double_layer_kernel(x, x, n)
        assert k == 0.0

    def test_kernel_sign_depends_on_normal(self):
        """Kernel sign changes with normal direction."""
        x = np.array([2.0, 0.0])
        y = np.array([0.0, 0.0])
        n_out = np.array([1.0, 0.0])  # Points toward x
        n_in = np.array([-1.0, 0.0])  # Points away from x

        k_out = laplace_double_layer_kernel(x, y, n_out)
        k_in = laplace_double_layer_kernel(x, y, n_in)

        assert k_out == pytest.approx(-k_in)


class TestDiagonalCorrection:
    """Tests for diagonal (self-interaction) correction."""

    def test_diagonal_flat(self):
        """Zero curvature should give zero diagonal."""
        kappa = 0.0
        diag = laplace_double_layer_diagonal(kappa)
        assert diag == 0.0

    def test_diagonal_circle(self):
        """Unit circle has κ=1, diagonal should be 1/(4π)."""
        kappa = 1.0
        diag = laplace_double_layer_diagonal(kappa)
        assert diag == pytest.approx(1 / (4 * np.pi))

    def test_diagonal_proportional_to_curvature(self):
        """Diagonal should be proportional to curvature."""
        kappa1, kappa2 = 2.0, 4.0
        d1 = laplace_double_layer_diagonal(kappa1)
        d2 = laplace_double_layer_diagonal(kappa2)

        assert d2 / d1 == pytest.approx(kappa2 / kappa1)

    def test_diagonal_negative_curvature(self):
        """Negative curvature (concave) should give negative diagonal."""
        kappa = -2.0
        diag = laplace_double_layer_diagonal(kappa)
        assert diag < 0
        assert diag == pytest.approx(-2.0 / (4 * np.pi))


class TestSingleLayerMatrix:
    """Tests for single-layer matrix assembly."""

    def test_matrix_shape(self):
        """Matrix should have correct shape."""
        M, N = 20, 32
        targets = np.random.randn(M, 2)
        sources = np.random.randn(N, 2)
        weights = np.ones(N) * 0.1

        A = laplace_single_layer_matrix(targets, sources, weights)
        assert A.shape == (M, N)

    def test_matrix_symmetric_self(self):
        """Self-interaction matrix should be symmetric."""
        n = 16
        theta = np.linspace(0, 2 * np.pi, n, endpoint=False)
        sources = np.column_stack([np.cos(theta), np.sin(theta)])
        weights = np.ones(n) * 2 * np.pi / n

        A = laplace_single_layer_matrix(sources, sources, weights)

        # Should be symmetric (up to diagonal treatment)
        # Off-diagonal should be symmetric
        A_no_diag = A - np.diag(np.diag(A))
        np.testing.assert_allclose(A_no_diag, A_no_diag.T, atol=1e-12)

    def test_matrix_includes_weights(self):
        """Matrix entries should scale with weights."""
        targets = np.array([[2.0, 0.0]])
        sources = np.array([[0.0, 0.0]])

        w1 = np.array([1.0])
        w2 = np.array([2.0])

        A1 = laplace_single_layer_matrix(targets, sources, w1)
        A2 = laplace_single_layer_matrix(targets, sources, w2)

        assert A2[0, 0] == pytest.approx(2 * A1[0, 0])


class TestDoubleLayerMatrix:
    """Tests for full matrix assembly."""

    def test_matrix_shape(self):
        """Matrix should be square with size = number of nodes."""
        n = 32
        targets = np.random.randn(n, 2)
        sources = targets.copy()
        normals = np.column_stack([
            np.cos(np.linspace(0, 2 * np.pi, n)),
            np.sin(np.linspace(0, 2 * np.pi, n)),
        ])
        weights = np.ones(n) * 2 * np.pi / n
        curvature = np.ones(n)

        K = laplace_double_layer_matrix(
            targets, sources, normals, weights,
            curvature=curvature, same_points=True
        )

        assert K.shape == (n, n)

    def test_matrix_diagonal_uses_curvature(self):
        """When same_points=True, diagonal should use curvature correction."""
        n = 16
        # Circle
        theta = np.linspace(0, 2 * np.pi, n, endpoint=False)
        sources = np.column_stack([np.cos(theta), np.sin(theta)])
        normals = sources.copy()  # Outward normals for unit circle
        weights = np.ones(n) * 2 * np.pi / n
        curvature = np.ones(n)  # κ = 1 for unit circle

        K = laplace_double_layer_matrix(
            sources, sources, normals, weights,
            curvature=curvature, same_points=True
        )

        # Diagonal should be κ/(4π) * weight = 1/(4π) * (2π/n) = 1/(2n)
        expected_diag = curvature / (4 * np.pi) * weights
        np.testing.assert_allclose(np.diag(K), expected_diag, rtol=1e-10)

    def test_matrix_requires_curvature_for_same_points(self):
        """Should raise if same_points=True but no curvature given."""
        n = 8
        pts = np.random.randn(n, 2)
        nrm = np.random.randn(n, 2)
        nrm = nrm / np.linalg.norm(nrm, axis=1, keepdims=True)
        w = np.ones(n)

        with pytest.raises(ValueError):
            laplace_double_layer_matrix(pts, pts, nrm, w, same_points=True)

    def test_matrix_different_target_source_sizes(self):
        """Should handle M != N when same_points=False."""
        M, N = 10, 20
        targets = np.random.randn(M, 2)
        sources = np.random.randn(N, 2)
        normals = np.random.randn(N, 2)
        normals = normals / np.linalg.norm(normals, axis=1, keepdims=True)
        weights = np.ones(N)

        K = laplace_double_layer_matrix(
            targets, sources, normals, weights, same_points=False
        )

        assert K.shape == (M, N)
