import json
import sys
from unittest.mock import MagicMock, Mock, patch

import numpy as np
import pandas as pd
import pytest

from nglui.statebuilder.base import (
    NumpyEncoder,
    UnservedViewer,
    ViewerState,
    webbrowser,
)
from nglui.statebuilder.ngl_components import (
    AnnotationLayer,
    CoordSpace,
    DataMap,
    ImageLayer,
    RawLayer,
    SegmentationLayer,
)


class TestUnservedViewer:
    def test_unserved_viewer_creation(self):
        viewer = UnservedViewer()
        assert viewer._default_viewer_url == ""

    def test_get_server_url(self):
        viewer = UnservedViewer()
        assert viewer.get_server_url() == ""

    def test_unserved_viewer_with_custom_url(self):
        viewer = UnservedViewer()
        viewer._default_viewer_url = "https://custom.neuroglancer.com"
        assert viewer.get_server_url() == "https://custom.neuroglancer.com"


class TestViewerStateCreation:
    def test_viewerstate_default_creation(self):
        vs = ViewerState()
        assert vs._target_site is None
        assert vs._position is None
        assert vs._scale_imagery == 1.0
        assert vs._scale_3d == 50000.0
        assert vs._show_slices is False
        assert vs._selected_layer is None
        assert vs._layout == "xy-3d"
        assert vs._interactive is False
        assert vs._infer_coordinates is True
        assert len(vs.layers) == 0

    def test_viewerstate_with_parameters(self):
        layers = [ImageLayer(name="test_img")]
        vs = ViewerState(
            dimensions=[4, 4, 40],
            position=[100, 100, 100],
            scale_imagery=2.0,
            scale_3d=25000.0,
            show_slices=True,
            selected_layer="test_img",
            layout="4panel",
            interactive=True,
            infer_coordinates=False,
            layers=layers,
        )
        assert vs._dimensions == [4, 4, 40]  # Internal storage before property access
        assert vs.position == [100, 100, 100]
        assert vs.scale_imagery == 2.0
        assert vs.scale_3d == 25000.0
        assert vs.show_slices is True
        assert vs._selected_layer == "test_img"
        assert vs.layout == "4panel"
        assert vs.interactive is True
        assert vs.infer_coordinates is False
        assert len(vs.layers) == 1


class TestViewerStateProperties:
    def test_dimensions_property(self):
        vs = ViewerState()

        # Test setting with list
        vs.dimensions = [4, 4, 40]
        assert isinstance(vs.dimensions, CoordSpace)
        assert vs.dimensions.resolution == [4, 4, 40]

        # Test setting with CoordSpace
        coord_space = CoordSpace(resolution=[8, 8, 80])
        vs.dimensions = coord_space
        assert vs.dimensions is coord_space

    def test_position_property(self):
        vs = ViewerState()
        position = [100, 200, 300]
        vs.position = position
        assert vs.position == position

    def test_scale_imagery_property(self):
        vs = ViewerState()
        vs.scale_imagery = 2.5
        assert vs.scale_imagery == 2.5

    def test_scale_3d_property(self):
        vs = ViewerState()
        vs.scale_3d = 75000.0
        assert vs.scale_3d == 75000.0

    def test_show_slices_property(self):
        vs = ViewerState()
        vs.show_slices = True
        assert vs.show_slices is True

    def test_layout_property_valid(self):
        vs = ViewerState()
        valid_layouts = [
            "xy",
            "yz",
            "xz",
            "xy-3d",
            "xz-3d",
            "yz-3d",
            "4panel",
            "3d",
            "4panel-alt",
        ]

        for layout in valid_layouts:
            vs.layout = layout
            assert vs.layout == layout

    def test_layout_property_invalid(self):
        vs = ViewerState()
        with pytest.raises(ValueError, match="Invalid layout"):
            vs.layout = "invalid_layout"

    def test_selected_layer_property(self):
        vs = ViewerState()
        vs._selected_layer = "test_layer"
        vs._selected_layer_visible = True

        selected = vs.selected_layer
        assert selected["layer"] == "test_layer"
        assert selected["visible"] is True

    def test_set_selected_layer_string(self):
        vs = ViewerState()
        result = vs.set_selected_layer("test_layer")
        assert vs._selected_layer == "test_layer"
        assert result is vs

    def test_set_selected_layer_layer_object(self):
        vs = ViewerState()
        layer = ImageLayer(name="test_img")
        vs.set_selected_layer(layer)
        assert vs._selected_layer == "test_img"

    def test_interactive_property(self):
        vs = ViewerState()
        vs.interactive = True
        assert vs.interactive is True

    def test_infer_coordinates_property(self):
        vs = ViewerState()
        vs.infer_coordinates = False
        assert vs.infer_coordinates is False

    def test_layer_names_property(self):
        vs = ViewerState()
        img_layer = ImageLayer(name="imagery")
        seg_layer = SegmentationLayer(name="segmentation")
        vs.add_layer([img_layer, seg_layer])

        assert vs.layer_names == ["imagery", "segmentation"]


class TestViewerStateLayerManagement:
    def test_add_layer_single(self):
        vs = ViewerState()
        layer = ImageLayer(name="test_img")
        result = vs.add_layer(layer)

        assert len(vs.layers) == 1
        assert vs.layers[0] is layer
        assert result is vs

    def test_add_layer_multiple(self):
        vs = ViewerState()
        layers = [ImageLayer(name="img1"), ImageLayer(name="img2")]
        vs.add_layer(layers)

        assert len(vs.layers) == 2
        assert vs.layers[0].name == "img1"
        assert vs.layers[1].name == "img2"

    def test_add_layer_with_selection(self):
        vs = ViewerState()
        layer = ImageLayer(name="test_img")
        vs.add_layer(layer, selected=True)

        assert vs._selected_layer == "test_img"

    def test_add_layer_multiple_with_selection(self):
        vs = ViewerState()
        layers = [ImageLayer(name="img1"), ImageLayer(name="img2")]
        vs.add_layer(layers, selected=[False, True])

        assert vs._selected_layer == "img2"

    def test_get_layer_by_name(self):
        vs = ViewerState()
        layer = ImageLayer(name="test_img")
        vs.add_layer(layer)

        retrieved = vs.layers["test_img"]
        assert retrieved is layer

    def test_add_image_layer(self):
        vs = ViewerState()
        result = vs.add_image_layer(
            source="precomputed://example", name="test_img", resolution=[4, 4, 40]
        )

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_img"
        assert isinstance(vs.layers[0], ImageLayer)
        assert result is vs

    def test_add_segmentation_layer(self):
        vs = ViewerState()
        result = vs.add_segmentation_layer(
            source="precomputed://example", name="test_seg", segments=[123, 456]
        )

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_seg"
        assert isinstance(vs.layers[0], SegmentationLayer)
        assert result is vs

    def test_add_annotation_layer(self):
        vs = ViewerState()
        result = vs.add_annotation_layer(name="test_anno", resolution=[4, 4, 40])

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_anno"
        assert isinstance(vs.layers[0], AnnotationLayer)
        assert result is vs


class TestViewerStateClientIntegration:
    @patch("caveclient.CAVEclient")
    def test_add_layers_from_client_basic(self, mock_caveclient):
        # Mock the client
        mock_client = Mock()
        mock_client.info.viewer_site.return_value = "https://neuroglancer.com"
        mock_client.info.viewer_resolution.return_value = [4, 4, 40]
        mock_client.info.image_source.return_value = "precomputed://img"
        mock_client.info.segmentation_source.return_value = "precomputed://seg"
        mock_client.info.get_datastack_info.return_value = {"skeleton_source": None}

        vs = ViewerState()
        result = vs.add_layers_from_client(mock_client)

        assert len(vs.layers) == 2  # Image and segmentation layers
        assert vs.layers[0].name == "imagery"
        assert vs.layers[1].name == "segmentation"
        assert result is vs

    @patch("caveclient.CAVEclient")
    def test_add_layers_from_client_with_skeleton(self, mock_caveclient):
        mock_client = Mock()
        mock_client.info.viewer_site.return_value = "https://neuroglancer.com"
        mock_client.info.viewer_resolution.return_value = [4, 4, 40]
        mock_client.info.image_source.return_value = "precomputed://img"
        mock_client.info.segmentation_source.return_value = "precomputed://seg"
        mock_client.info.get_datastack_info.return_value = {
            "skeleton_source": "precomputed://skel"
        }

        vs = ViewerState()
        vs.add_layers_from_client(mock_client, skeleton_source=True)

        # Should have added both segmentation and skeleton sources
        seg_layer = vs.layers["segmentation"]
        assert len(seg_layer.source) == 2

    def test_add_layers_from_client_no_client_error(self):
        vs = ViewerState()
        with pytest.raises(ValueError, match="No client provided"):
            vs.add_layers_from_client()

    @patch("caveclient.CAVEclient")
    def test_add_layers_from_client_custom_names(self, mock_caveclient):
        mock_client = Mock()
        mock_client.info.viewer_site.return_value = "https://neuroglancer.com"
        mock_client.info.viewer_resolution.return_value = [4, 4, 40]
        mock_client.info.image_source.return_value = "precomputed://img"
        mock_client.info.segmentation_source.return_value = "precomputed://seg"
        mock_client.info.get_datastack_info.return_value = {"skeleton_source": None}

        vs = ViewerState()
        vs.add_layers_from_client(
            mock_client, imagery="custom_img", segmentation="custom_seg"
        )

        assert vs.layers[0].name == "custom_img"
        assert vs.layers[1].name == "custom_seg"


class TestViewerStateSetViewerProperties:
    def test_set_viewer_properties_all_parameters(self):
        vs = ViewerState()
        layer = ImageLayer(name="test_img")

        result = vs.set_viewer_properties(
            position=[100, 200, 300],
            dimensions=[4, 4, 40],
            scale_imagery=2.0,
            scale_3d=75000.0,
            show_slices=True,
            selected_layer=layer,
            layout="4panel",
            base_state={"test": "state"},
            interactive=True,
            infer_coordinates=False,
        )

        assert vs.position == [100, 200, 300]
        assert vs.dimensions.resolution == [4, 4, 40]
        assert vs.scale_imagery == 2.0
        assert vs.scale_3d == 75000.0
        assert vs.show_slices is True
        assert vs._selected_layer == "test_img"
        assert vs.layout == "4panel"
        assert vs.base_state == {"test": "state"}
        assert vs.interactive is True
        assert vs.infer_coordinates is False
        assert result is vs

    def test_set_viewer_properties_partial(self):
        vs = ViewerState()
        original_scale = vs.scale_imagery

        vs.set_viewer_properties(position=[100, 100, 100])

        assert vs.position == [100, 100, 100]
        assert vs.scale_imagery == original_scale  # Should remain unchanged


class TestViewerStateDataMapping:
    def test_with_datamap_context_manager(self):
        vs = ViewerState()
        datamap = {"test_key": "test_value"}

        with vs.with_datamap(datamap) as mapped_vs:
            assert mapped_vs is not vs  # Should be a copy
            # The actual datamap application would depend on layers having datamaps

    def test_map_method(self):
        vs = ViewerState()
        datamap = {"test_key": "test_value"}

        # Test inplace=False (default)
        mapped_vs = vs.map(datamap, inplace=False)
        assert mapped_vs is not vs

        # Test inplace=True
        original_vs = vs
        result = vs.map(datamap, inplace=True)
        assert result is original_vs


class TestViewerStateAnnotationMethods:
    def test_add_points_basic(self):
        vs = ViewerState(dimensions=[4, 4, 40])

        # Test that the method exists and creates a layer
        # Skip the complex DataFrame processing for now
        result = vs.add_annotation_layer(name="test_points")

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_points"
        assert isinstance(vs.layers[0], AnnotationLayer)
        assert result is vs

    def test_add_points_with_list_columns(self):
        """Test ViewerState add_points with point_column as list of column names"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Create test DataFrame with separate coordinate columns
        df = pd.DataFrame(
            {
                "coord_x": [10, 20, 30],
                "coord_y": [15, 25, 35],
                "coord_z": [5, 7, 10],
                "segment": [100, 200, 300],
                "desc": ["pt1", "pt2", "pt3"],
            }
        )

        # Test add_points with list of column names
        result = vs.add_points(
            data=df,
            point_column=["coord_x", "coord_y", "coord_z"],
            segment_column="segment",
            description_column="desc",
            name="list_column_points",
        )

        # Should return self for chaining
        assert result is vs

        # Should have created a layer
        assert len(vs.layers) == 1
        layer = vs.layers[0]
        assert layer.name == "list_column_points"
        assert isinstance(layer, AnnotationLayer)

        # Should have annotations (exact number depends on implementation)
        assert len(layer.annotations) > 0

    def test_add_points_with_prefix_expansion(self):
        """Test ViewerState add_points with point_column as prefix that expands to _x,_y,_z"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Create test DataFrame with prefixed coordinate columns
        df = pd.DataFrame(
            {
                "location_x": [100, 200],
                "location_y": [150, 250],
                "location_z": [50, 75],
                "cell_id": [1001, 1002],
            }
        )

        # Test add_points with prefix that should expand
        result = vs.add_points(
            data=df,
            point_column="location",  # Should expand to location_x, location_y, location_z
            segment_column="cell_id",
            name="prefix_points",
        )

        # Should work without error
        assert result is vs
        assert len(vs.layers) == 1
        assert vs.layers[0].name == "prefix_points"

    def test_add_lines_basic(self):
        vs = ViewerState(dimensions=[4, 4, 40])

        result = vs.add_annotation_layer(name="test_lines")

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_lines"
        assert isinstance(vs.layers[0], AnnotationLayer)
        assert result is vs

    def test_add_ellipsoids_basic(self):
        vs = ViewerState(dimensions=[4, 4, 40])

        result = vs.add_annotation_layer(name="test_ellipsoids")

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_ellipsoids"
        assert isinstance(vs.layers[0], AnnotationLayer)
        assert result is vs

    def test_add_boxes_basic(self):
        vs = ViewerState(dimensions=[4, 4, 40])

        result = vs.add_annotation_layer(name="test_boxes")

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_boxes"
        assert isinstance(vs.layers[0], AnnotationLayer)
        assert result is vs

    def test_add_lines_with_data(self):
        """Test add_lines with actual DataFrame data"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Create test data with line coordinates
        df = pd.DataFrame(
            {
                "start_x": [10, 50],
                "start_y": [20, 60],
                "start_z": [30, 70],
                "end_x": [40, 80],
                "end_y": [50, 90],
                "end_z": [60, 100],
                "segment": [111, 222],
                "desc": ["line1", "line2"],
            }
        )

        result = vs.add_lines(
            data=df,
            point_a_column=["start_x", "start_y", "start_z"],
            point_b_column=["end_x", "end_y", "end_z"],
            segment_column="segment",
            description_column="desc",
            name="test_lines_data",
        )

        assert result is vs
        assert len(vs.layers) == 1
        layer = vs.layers[0]
        assert layer.name == "test_lines_data"
        assert len(layer.annotations) > 0

    def test_add_ellipsoids_with_data(self):
        """Test add_ellipsoids with actual DataFrame data"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Create test data with ellipsoid parameters
        df = pd.DataFrame(
            {
                "center_x": [100, 200],
                "center_y": [150, 250],
                "center_z": [50, 75],
                "radius_x": [10, 15],
                "radius_y": [10, 15],
                "radius_z": [5, 8],
                "cell_id": [1001, 1002],
                "type": ["soma", "nucleus"],
            }
        )

        result = vs.add_ellipsoids(
            data=df,
            center_column=["center_x", "center_y", "center_z"],
            radii_column=["radius_x", "radius_y", "radius_z"],
            segment_column="cell_id",
            description_column="type",
            name="test_ellipsoids_data",
        )

        assert result is vs
        assert len(vs.layers) == 1
        layer = vs.layers[0]
        assert layer.name == "test_ellipsoids_data"
        assert len(layer.annotations) > 0

    def test_add_boxes_with_data(self):
        """Test add_boxes with actual DataFrame data"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Create test data with bounding box coordinates
        df = pd.DataFrame(
            {
                "min_x": [0, 100],
                "min_y": [0, 150],
                "min_z": [0, 50],
                "max_x": [50, 200],
                "max_y": [75, 250],
                "max_z": [25, 100],
                "region_id": [501, 502],
                "region_name": ["area1", "area2"],
            }
        )

        result = vs.add_boxes(
            data=df,
            point_a_column=["min_x", "min_y", "min_z"],
            point_b_column=["max_x", "max_y", "max_z"],
            segment_column="region_id",
            description_column="region_name",
            name="test_boxes_data",
        )

        assert result is vs
        assert len(vs.layers) == 1
        layer = vs.layers[0]
        assert layer.name == "test_boxes_data"
        assert len(layer.annotations) > 0

    def test_add_segments_comprehensive(self):
        """Test add_segments with various data formats"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # First add a segmentation layer
        vs.add_segmentation_layer(source="precomputed://test_seg", name="main_seg")

        # Test adding segments as list
        result = vs.add_segments([12345, 67890, 11111])
        assert result is vs

        # Test adding segments with specific layer
        result = vs.add_segments([22222, 33333], name="main_seg")
        assert result is vs

        # Verify segments were added
        seg_layer = vs.layers[0]
        assert 12345 in seg_layer.segments
        assert 67890 in seg_layer.segments
        assert 22222 in seg_layer.segments

    def test_add_segments_from_data_comprehensive(self):
        """Test add_segments_from_data with DataFrame"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Add segmentation layer first
        vs.add_segmentation_layer(source="precomputed://test", name="seg_layer")

        # Create test DataFrame with segment data
        df = pd.DataFrame(
            {
                "segment_id": [100, 200, 300, 400],
                "visible": [True, False, True, True],
                "color": ["red", "blue", "green", "yellow"],
            }
        )

        result = vs.add_segments_from_data(
            data=df,
            segment_column="segment_id",
            visible_column="visible",
            color_column="color",
            name="seg_layer",
        )

        assert result is vs

        # Verify segments and properties were added
        seg_layer = vs.layers[0]
        assert 100 in seg_layer.segments
        assert 200 in seg_layer.segments
        assert seg_layer.segments[100] is True  # visible
        assert seg_layer.segments[200] is False  # not visible

    def test_add_raw_layer_comprehensive(self):
        """Test add_raw_layer with complex data"""
        vs = ViewerState(dimensions=[4, 4, 40])

        raw_data = {
            "name": "custom_raw",
            "source": "precomputed://custom_source",
            "opacity": 0.8,
            "blend": "additive",
            "visible": True,
        }

        result = vs.add_raw_layer(
            data=raw_data,
            name="raw_test",
            visible=False,  # Override visibility
            archived=True,
        )

        assert result is vs
        assert len(vs.layers) == 1
        layer = vs.layers[0]
        assert layer.name == "raw_test"
        assert layer.visible is False  # Should use the override
        assert layer.archived is True


class TestViewerStateWorkflows:
    """Test complex workflows and integrations"""

    def test_multi_layer_annotation_workflow(self):
        """Test creating multiple annotation layers with different types"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Create points layer
        points_df = pd.DataFrame(
            {
                "x": [10, 20],
                "y": [15, 25],
                "z": [5, 8],
                "id": [100, 200],
                "type": ["cell", "synapse"],
            }
        )
        vs.add_points(
            data=points_df,
            point_column=["x", "y", "z"],
            segment_column="id",
            description_column="type",
            name="points",
        )

        # Create lines layer
        lines_df = pd.DataFrame(
            {
                "x1": [10, 30],
                "y1": [15, 35],
                "z1": [5, 10],
                "x2": [20, 40],
                "y2": [25, 45],
                "z2": [8, 15],
                "conn_id": [1001, 1002],
            }
        )
        vs.add_lines(
            data=lines_df,
            point_a_column=["x1", "y1", "z1"],
            point_b_column=["x2", "y2", "z2"],
            segment_column="conn_id",
            name="connections",
        )

        # Should have created 2 annotation layers
        assert len(vs.layers) == 2
        assert vs.layers[0].name == "points"
        assert vs.layers[1].name == "connections"
        assert all(len(layer.annotations) > 0 for layer in vs.layers)

    def test_layered_data_with_segmentation(self):
        """Test workflow with segmentation + annotations + imagery"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Add base imagery layer
        vs.add_image_layer(
            source="precomputed://imagery", name="base_image", opacity=0.7
        )

        # Add segmentation layer with segments
        vs.add_segmentation_layer(source="precomputed://segmentation", name="cells")
        vs.add_segments([12345, 67890, 54321])

        # Add annotations linked to segmentation
        vs.add_annotation_layer(name="annotations", linked_segmentation="cells")

        # Add points referencing the segmentation IDs
        points_df = pd.DataFrame(
            {
                "pos_x": [100, 200, 300],
                "pos_y": [150, 250, 350],
                "pos_z": [50, 75, 100],
                "segment_id": [12345, 67890, 54321],
                "note": ["soma", "dendrite", "axon"],
            }
        )
        vs.add_points(
            data=points_df,
            point_column=["pos_x", "pos_y", "pos_z"],
            segment_column="segment_id",
            description_column="note",
            name="annotations",
        )

        # Should have 3 layers total
        assert len(vs.layers) == 3

        # Check layer types and linkage
        image_layer = next(layer for layer in vs.layers if layer.name == "base_image")
        seg_layer = next(layer for layer in vs.layers if layer.name == "cells")
        anno_layer = next(layer for layer in vs.layers if layer.name == "annotations")

        assert hasattr(image_layer, "opacity")
        assert len(seg_layer.segments) == 3
        assert anno_layer.linked_segmentation == "cells"

    def test_datamap_priority_workflow(self):
        """Test workflow using DataMaps with different priorities"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Add segmentation layer that will use DataMaps
        seg_layer = vs.add_segmentation_layer(
            source="precomputed://test", name="priority_test"
        )

        # Create DataMaps with different priorities
        from nglui.statebuilder.ngl_components import DataMap

        high_priority_dm = DataMap(key="high_priority_segments")
        high_priority_dm._adjust_priority(1)

        low_priority_dm = DataMap(key="low_priority_segments")
        low_priority_dm._adjust_priority(10)

        # Register DataMaps (this simulates what would happen in real usage)
        layer = vs.layers[0]
        layer._register_datamap(
            high_priority_dm, lambda segments: layer.add_segments(segments)
        )
        layer._register_datamap(
            low_priority_dm, lambda segments: layer.add_segments(segments)
        )

        # Verify DataMaps are registered with correct priorities
        assert "high_priority_segments" in layer._datamaps
        assert "low_priority_segments" in layer._datamaps
        assert layer._datamap_priority["high_priority_segments"] == 1
        assert layer._datamap_priority["low_priority_segments"] == 10

        # Layer should not be static anymore
        assert layer.is_static is False

    def test_complex_coordinate_handling(self):
        """Test various coordinate column formats in one workflow"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Test 1: Explicit column lists
        df1 = pd.DataFrame(
            {
                "coord_x": [10, 20],
                "coord_y": [15, 25],
                "coord_z": [5, 8],
                "segment": [100, 200],
            }
        )
        vs.add_points(
            data=df1,
            point_column=["coord_x", "coord_y", "coord_z"],
            segment_column="segment",
            name="explicit_columns",
        )

        # Test 2: Prefix expansion
        df2 = pd.DataFrame(
            {
                "position_x": [30, 40],
                "position_y": [35, 45],
                "position_z": [10, 15],
                "segment": [300, 400],
            }
        )
        vs.add_points(
            data=df2,
            point_column="position",
            segment_column="segment",
            name="prefix_expansion",
        )

        # Test 3: Single column (if supported)
        df3 = pd.DataFrame(
            {
                "full_position": [[50, 55, 20], [60, 65, 25]],  # Array column
                "segment": [500, 600],
            }
        )
        vs.add_points(
            data=df3,
            point_column="full_position",
            segment_column="segment",
            name="array_column",
        )

        # Should create 3 annotation layers
        assert len(vs.layers) == 3
        assert all(len(layer.annotations) > 0 for layer in vs.layers)

    def test_error_recovery_workflow(self):
        """Test workflow continues after recoverable errors"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Add a valid layer first
        vs.add_image_layer(source="precomputed://valid", name="good_layer")

        # Try to add invalid data - should handle gracefully
        try:
            vs.add_points(data=None, point_column="nonexistent", name="bad_points")
        except (ValueError, AttributeError, KeyError):
            pass  # Expected for invalid data

        # Continue with valid operations
        vs.add_segmentation_layer(source="precomputed://segments", name="segments")
        vs.add_segments([111, 222, 333])

        # Should have 3 layers total (good_layer, bad_points created but failed, segments)
        assert len(vs.layers) == 3
        assert vs.layers[0].name == "good_layer"
        assert (
            vs.layers[1].name == "bad_points"
        )  # Layer was created even though data failed
        assert vs.layers[2].name == "segments"

    def test_method_chaining_workflow(self):
        """Test that all methods support chaining properly"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Test method chaining
        result = (
            vs.add_image_layer(source="precomputed://img", name="image")
            .add_segmentation_layer(source="precomputed://seg", name="seg")
            .add_segments([1, 2, 3])
            .add_annotation_layer(name="anno", linked_segmentation="seg")
        )

        # All methods should return self for chaining
        assert result is vs
        assert len(vs.layers) == 3

        # Verify the chain worked correctly
        assert vs.layers[0].name == "image"
        assert vs.layers[1].name == "seg"
        assert vs.layers[2].name == "anno"
        assert vs.layers[2].linked_segmentation == "seg"


class TestViewerStateSourceInfo:
    @patch("nglui.statebuilder.source_info.populate_info")
    def test_source_info_property(self, mock_populate_info):
        mock_populate_info.return_value = {"test": "info"}

        vs = ViewerState()
        layer = ImageLayer(name="test", source="precomputed://example")
        vs.add_layer(layer)

        info = vs.source_info
        assert info == {"test": "info"}
        mock_populate_info.assert_called_once_with(vs.layers)

    @patch("nglui.statebuilder.source_info.suggest_position")
    def test_suggest_position_from_source(self, mock_suggest_position):
        mock_suggest_position.return_value = np.array([100, 200, 300])

        vs = ViewerState()
        position = vs._suggest_position_from_source([4, 4, 40])

        assert np.array_equal(position, [100, 200, 300])
        mock_suggest_position.assert_called_once()

    @patch("nglui.statebuilder.source_info.suggest_resolution")
    def test_suggest_resolution_from_source(self, mock_suggest_resolution):
        mock_suggest_resolution.return_value = np.array([4, 4, 40])

        vs = ViewerState()
        resolution = vs._suggest_resolution_from_source()

        assert np.array_equal(resolution, [4, 4, 40])
        mock_suggest_resolution.assert_called_once()


class TestViewerStateNeuroglancerConversion:
    @patch("nglui.statebuilder.source_info.populate_info")
    def test_to_neuroglancer_state(self, mock_populate_info):
        mock_populate_info.return_value = {}
        vs = ViewerState(dimensions=[4, 4, 40], infer_coordinates=False)
        # Don't add any layers to avoid the neuroglancer conversion issues

        ng_state = vs.to_neuroglancer_state()

        assert ng_state is not None

    def test_to_dict(self):
        vs = ViewerState(dimensions=[4, 4, 40])

        with patch.object(vs, "to_neuroglancer_state") as mock_to_ng:
            mock_ng_state = Mock()
            mock_ng_state.state.to_json.return_value = {"test": "dict"}
            mock_to_ng.return_value = mock_ng_state

            result = vs.to_dict()
            assert result == {"test": "dict"}

    def test_to_json_string(self):
        vs = ViewerState()

        with patch.object(vs, "to_dict") as mock_to_dict:
            mock_to_dict.return_value = {"test": "data"}

            result = vs.to_json_string(indent=4)
            expected = json.dumps({"test": "data"}, indent=4)
            assert result == expected

    def test_to_json_string_compact(self):
        """Test that compact parameter removes newlines and spaces"""
        vs = ViewerState()

        test_data = {"key": "value", "array": [1, 2, 3], "nested": {"inner": "data"}}

        with patch.object(vs, "to_dict") as mock_to_dict:
            mock_to_dict.return_value = test_data

            # Test default (pretty-printed with indent)
            result_default = vs.to_json_string()
            assert "\n" in result_default  # Should have newlines
            assert "  " in result_default  # Should have indentation

            # Test compact=False (explicit pretty-print)
            result_pretty = vs.to_json_string(compact=False, indent=2)
            assert "\n" in result_pretty  # Should have newlines
            assert "  " in result_pretty  # Should have indentation

            # Test compact=True (minimal JSON)
            result_compact = vs.to_json_string(compact=True)
            assert "\n" not in result_compact  # No newlines
            assert ": " not in result_compact  # No space after colon
            assert ", " not in result_compact  # No space after comma

            # Verify it's still valid JSON
            parsed = json.loads(result_compact)
            assert parsed == test_data

            # Verify compact output matches expected format
            expected_compact = json.dumps(test_data, separators=(",", ":"))
            assert result_compact == expected_compact

    def test_to_url(self):
        vs = ViewerState()

        with patch.object(vs, "to_json_string") as mock_to_json:
            mock_to_json.return_value = '{"test": "state"}'

            url = vs.to_url()

            # Just check that a URL is returned
            assert isinstance(url, str)
            assert len(url) > 0

    def test_to_link(self):
        vs = ViewerState()

        with patch.object(vs, "to_url") as mock_to_url:
            mock_to_url.return_value = "https://neuroglancer.com/test"

            link = vs.to_link(link_text="Test Link")

            assert "Test Link" in link.data
            assert "https://neuroglancer.com/test" in link.data

    def test_to_link_shortener(self):
        mock_client = Mock()
        mock_client.state.upload_state_json.return_value = 12345
        mock_client.state.build_neuroglancer_url.return_value = (
            "https://neuroglancer.com/12345"
        )

        vs = ViewerState()

        with patch.object(vs, "to_json_string") as mock_to_json:
            mock_to_json.return_value = '{"test": "state"}'

            url = vs.to_link_shortener(mock_client)

            assert "12345" in str(url)
            mock_client.state.upload_state_json.assert_called_once()

    def test_to_clipboard(self):
        if sys.platform == "linux":
            # punting on Linux clipboard handling for now
            # REF: https://github.com/asweigart/pyperclip/issues/259
            return

        vs = ViewerState()

        with patch.object(vs, "to_json_string") as mock_to_json:
            mock_to_json.return_value = '{"test": "state"}'

            url = vs.to_clipboard()

            # Just check that a URL is returned
            assert isinstance(url, str)
            assert len(url) > 0

    def test_to_browser(self):
        vs = ViewerState()

        with patch.object(vs, "to_json_string") as mock_to_json:
            mock_to_json.return_value = '{"test": "state"}'

        with patch.object(webbrowser, "open", return_value=True) as mock_open:
            url = vs.to_browser()
            mock_open.assert_called()  # Ensures the function was called, but no browser opens

            # Just check that a URL is returned
            assert isinstance(url, str)
            assert len(url) > 0


class TestViewerStateValidation:
    def test_viewer_property_lazy_loading(self):
        vs = ViewerState()

        with patch.object(vs, "to_neuroglancer_state") as mock_to_ng:
            mock_ng_state = Mock()
            mock_to_ng.return_value = mock_ng_state

            # First access should call to_neuroglancer_state
            viewer1 = vs.viewer
            mock_to_ng.assert_called_once()

            # Second access should use cached version
            viewer2 = vs.viewer
            assert viewer1 is viewer2
            mock_to_ng.assert_called_once()  # Still only called once

    def test_reset_viewer_on_property_changes(self):
        vs = ViewerState()

        with patch.object(vs, "_reset_viewer") as mock_reset:
            vs.dimensions = [4, 4, 40]
            mock_reset.assert_called_once()

        with patch.object(vs, "_reset_viewer") as mock_reset:
            vs.position = [100, 100, 100]
            mock_reset.assert_called_once()

        with patch.object(vs, "_reset_viewer") as mock_reset:
            vs.scale_imagery = 2.0
            mock_reset.assert_called_once()


class TestViewerStateEdgeCases:
    def test_add_annotation_source(self):
        vs = ViewerState()
        result = vs.add_annotation_source(
            source="precomputed://annotations", name="test_annotations"
        )

        assert len(vs.layers) == 1
        assert vs.layers[0].name == "test_annotations"
        assert vs.layers[0].source == "precomputed://annotations"
        assert result is vs

    def test_get_layer_by_name(self):
        vs = ViewerState()
        layer = ImageLayer(name="test_layer")
        vs.add_layer(layer)

        retrieved = vs.get_layer("test_layer")
        assert retrieved is layer

    def test_get_layer_nonexistent(self):
        vs = ViewerState()

        with pytest.raises(ValueError, match="Layer nonexistent_layer not found"):
            vs.get_layer("nonexistent_layer")


class TestNumpyEncoder:
    """Test custom JSON encoder for numpy types"""

    def test_numpy_int_encoding(self):
        """Test that numpy integers are converted to Python ints"""
        encoder = NumpyEncoder()

        # Test various numpy integer types
        assert encoder.default(np.int64(42)) == 42
        assert encoder.default(np.int32(100)) == 100
        assert encoder.default(np.int16(50)) == 50
        assert isinstance(encoder.default(np.int64(42)), int)

    def test_numpy_float_encoding(self):
        """Test that numpy floats are converted to Python floats"""
        encoder = NumpyEncoder()

        # Test various numpy float types
        assert encoder.default(np.float64(3.14)) == 3.14
        assert encoder.default(np.float32(2.71)) == pytest.approx(2.71, rel=1e-6)
        assert isinstance(encoder.default(np.float64(3.14)), float)

    def test_numpy_bool_encoding(self):
        """Test that numpy booleans are converted to Python bools"""
        encoder = NumpyEncoder()

        assert encoder.default(np.bool_(True)) is True
        assert encoder.default(np.bool_(False)) is False
        assert isinstance(encoder.default(np.bool_(True)), bool)

    def test_numpy_array_encoding(self):
        """Test that numpy arrays are converted to Python lists"""
        encoder = NumpyEncoder()

        # 1D array
        arr_1d = np.array([1, 2, 3, 4, 5])
        result_1d = encoder.default(arr_1d)
        assert result_1d == [1, 2, 3, 4, 5]
        assert isinstance(result_1d, list)

        # 2D array
        arr_2d = np.array([[1, 2], [3, 4]])
        result_2d = encoder.default(arr_2d)
        assert result_2d == [[1, 2], [3, 4]]
        assert isinstance(result_2d, list)

        # Array with floats
        arr_float = np.array([1.1, 2.2, 3.3])
        result_float = encoder.default(arr_float)
        assert result_float == pytest.approx([1.1, 2.2, 3.3])

    def test_pandas_series_encoding(self):
        """Test that pandas Series are converted to Python lists"""
        encoder = NumpyEncoder()

        series = pd.Series([10, 20, 30, 40])
        result = encoder.default(series)
        assert result == [10, 20, 30, 40]
        assert isinstance(result, list)

        # Series with different dtypes
        series_float = pd.Series([1.5, 2.5, 3.5])
        result_float = encoder.default(series_float)
        assert result_float == pytest.approx([1.5, 2.5, 3.5])

    def test_json_dumps_with_numpy_types(self):
        """Test json.dumps with NumpyEncoder for complex nested structures"""
        data = {
            "int_value": np.int64(42),
            "float_value": np.float64(3.14159),
            "bool_value": np.bool_(True),
            "array": np.array([1, 2, 3]),
            "nested": {
                "another_int": np.int32(100),
                "another_array": np.array([[1, 2], [3, 4]]),
            },
            "series": pd.Series([10, 20, 30]),
        }

        # Should not raise an error
        result = json.dumps(data, cls=NumpyEncoder)
        assert isinstance(result, str)

        # Deserialize and verify
        parsed = json.loads(result)
        assert parsed["int_value"] == 42
        assert parsed["float_value"] == pytest.approx(3.14159)
        assert parsed["bool_value"] is True
        assert parsed["array"] == [1, 2, 3]
        assert parsed["nested"]["another_int"] == 100
        assert parsed["nested"]["another_array"] == [[1, 2], [3, 4]]
        assert parsed["series"] == [10, 20, 30]

    def test_viewerstate_to_json_string_with_numpy(self):
        """Test that ViewerState.to_json_string handles numpy types correctly"""
        vs = ViewerState(dimensions=[4, 4, 40])

        # Mock to_dict to return data with numpy types
        with patch.object(vs, "to_dict") as mock_to_dict:
            mock_to_dict.return_value = {
                "position": np.array([100, 200, 300]),
                "scale": np.float64(1.5),
                "id": np.int64(12345),
                "visible": np.bool_(True),
            }

            # Should not raise an error
            result = vs.to_json_string(indent=2)
            assert isinstance(result, str)

            # Verify it's valid JSON
            parsed = json.loads(result)
            assert parsed["position"] == [100, 200, 300]
            assert parsed["scale"] == pytest.approx(1.5)
            assert parsed["id"] == 12345
            assert parsed["visible"] is True

    def test_edge_cases(self):
        """Test edge cases for numpy type encoding"""
        encoder = NumpyEncoder()

        # Zero values
        assert encoder.default(np.int64(0)) == 0
        assert encoder.default(np.float64(0.0)) == 0.0

        # Negative values
        assert encoder.default(np.int64(-42)) == -42
        assert encoder.default(np.float64(-3.14)) == pytest.approx(-3.14)

        # Large values
        large_int = np.int64(9223372036854775807)  # Max int64
        assert encoder.default(large_int) == 9223372036854775807

        # Empty array
        empty_array = np.array([])
        assert encoder.default(empty_array) == []

        # Empty series
        empty_series = pd.Series([])
        assert encoder.default(empty_series) == []


def test_raw_layer_basic():
    # Minimal raw layer data
    raw_data = {
        "type": "image",
        "source": "precomputed://some/path",
        "name": "raw_img",
        "visible": True,
        "archived": False,
    }
    # Create RawLayer instance
    layer = RawLayer(json_data=raw_data)
    assert layer.json_data["type"] == "image"
    assert (
        layer.name == "raw_img" or layer.name is None
    )  # name may be set by field or from json_data
    assert layer.visible is True
    assert layer.archived is False

    # Test to_neuroglancer_layer returns a Layer object
    ng_layer = layer.to_neuroglancer_layer()
    assert hasattr(ng_layer, "to_json")

    # Test to_dict returns a dict with expected keys
    layer_dict = layer.to_dict()
    assert "type" in layer_dict
    assert "source" in layer_dict

    # Test to_json returns a valid JSON string
    json_str = layer.to_json()
    assert isinstance(json_str, str)
    assert '"type": "image"' in json_str


def test_raw_layer_name_and_source_remap():
    from src.nglui.statebuilder.ngl_components import RawLayer

    # Initial raw layer data
    raw_data = {
        "type": "image",
        "source": "precomputed://old/path",
        "name": "raw_img",
        "visible": True,
        "archived": False,
    }
    # Create RawLayer with a new name and visibility
    layer = RawLayer(json_data=raw_data, name="new_name", visible=False, archived=True)
    assert layer.name == "new_name"
    assert layer.visible is False
    assert layer.archived is True
    # Remap the source
    remap = {"precomputed://old/path": "precomputed://new/path"}
    layer.remap_sources(remap)
    # The json_data should have the new source
    assert layer.json_data["source"] == "precomputed://new/path"
    # to_dict should reflect the new source
    layer_dict = layer.to_dict()
    assert layer_dict["source"] == "precomputed://new/path"
