import csv
import json
import os
import shutil
import unittest
from io import StringIO
from typing import Any
from unittest.mock import patch

import pandas as pd
from nextmv.base_model import BaseModel

import nextmv


class TestOutput(unittest.TestCase):
    """Tests for the various classes for writing an output."""

    def test_post_init_validation(self):
        """Test the validation in __post_init__ for different scenarios."""

        # Test with None solution - should not raise any errors
        output = nextmv.Output()
        self.assertIsNone(output.solution)

        # Test valid JSON serializable object
        output = nextmv.Output(solution={"test": 123})
        self.assertEqual(output.solution, {"test": 123})

        # Test JSON with non-serializable object
        with self.assertRaises(ValueError) as context:

            class NonSerializable:
                pass

            nextmv.Output(solution=NonSerializable())

        self.assertIn("which is not JSON serializable", str(context.exception))

        # Test CSV_ARCHIVE with valid dict
        output = nextmv.Output(
            output_format=nextmv.OutputFormat.CSV_ARCHIVE, solution={"file": [{"col1": 1, "col2": 2}]}
        )
        self.assertEqual(output.solution, {"file": [{"col1": 1, "col2": 2}]})

        # Test CSV_ARCHIVE with non-dict
        with self.assertRaises(ValueError) as context:
            nextmv.Output(output_format=nextmv.OutputFormat.CSV_ARCHIVE, solution=["not a dict"])

        self.assertIn("supported type is `dict`", str(context.exception))

    def test_post_init_options_copied(self):
        """Test that options are deep-copied in __post_init__."""

        options = {"duration": 10}
        output = nextmv.Output(options=options)

        # Modify the original options
        options["duration"] = 20

        # The output's options should not be affected by the modification
        self.assertEqual(output.options["duration"], 10)

    def test_to_dict(self):
        """Test the to_dict method for different cases."""

        # Test with None values for options, statistics, and assets
        output = nextmv.Output()
        expected = {
            "options": {},
            "solution": {},
            "statistics": {},
            "assets": [],
        }
        self.assertDictEqual(output.to_dict(), expected)

        # Test with Options object
        options = nextmv.Options()
        options.duration = 30
        output = nextmv.Output(options=options)
        result = output.to_dict()
        self.assertEqual(result["options"]["duration"], 30)

        # Test with dictionary options
        options_dict = {"duration": 45, "threads": 4}
        output = nextmv.Output(options=options_dict)
        result = output.to_dict()
        self.assertEqual(result["options"]["duration"], 45)
        self.assertEqual(result["options"]["threads"], 4)

        # Test with Statistics object
        run_stats = nextmv.RunStatistics(duration=10.5, iterations=100)
        statistics = nextmv.Statistics(run=run_stats)
        output = nextmv.Output(statistics=statistics)
        result = output.to_dict()
        self.assertEqual(result["statistics"]["run"]["duration"], 10.5)
        self.assertEqual(result["statistics"]["run"]["iterations"], 100)

        # Test with dictionary statistics
        stats_dict = {"custom_metric": 123.45}
        output = nextmv.Output(statistics=stats_dict)
        result = output.to_dict()
        self.assertEqual(result["statistics"]["custom_metric"], 123.45)

        # Test with list of Asset objects
        asset1 = nextmv.Asset(name="asset1", content={"data": [1, 2, 3]}, description="Test asset")
        asset2 = nextmv.Asset(
            name="asset2",
            content={"data": "value"},
        )
        output = nextmv.Output(assets=[asset1, asset2])
        result = output.to_dict()
        self.assertEqual(len(result["assets"]), 2)
        self.assertEqual(result["assets"][0]["name"], "asset1")
        self.assertEqual(result["assets"][1]["name"], "asset2")

        # Test with list of dictionary assets
        asset_dicts = [{"name": "asset3", "content": {"data": [4, 5, 6]}, "content_type": "json"}]
        output = nextmv.Output(assets=asset_dicts)
        result = output.to_dict()
        self.assertEqual(result["assets"][0]["name"], "asset3")

        # Test with CSV configurations
        csv_config = {"delimiter": ";", "quoting": csv.QUOTE_NONNUMERIC}
        output = nextmv.Output(output_format=nextmv.OutputFormat.CSV_ARCHIVE, csv_configurations=csv_config)
        result = output.to_dict()
        self.assertEqual(result["csv_configurations"]["delimiter"], ";")
        self.assertEqual(result["csv_configurations"]["quoting"], csv.QUOTE_NONNUMERIC)

        # Test with invalid options type
        with self.assertRaises(TypeError) as context:
            output = nextmv.Output(options=123)
            output.to_dict()
        self.assertIn("unsupported options type", str(context.exception))

        # Test with invalid statistics type
        with self.assertRaises(TypeError) as context:
            output = nextmv.Output(statistics=123)
            output.to_dict()
        self.assertIn("unsupported statistics type", str(context.exception))

        # Test with invalid assets type
        with self.assertRaises(TypeError) as context:
            output = nextmv.Output(assets=123)
            output.to_dict()
        self.assertIn("unsupported assets type", str(context.exception))

        # Test with invalid asset in assets list
        with self.assertRaises(TypeError) as context:
            output = nextmv.Output(assets=[123])
            output.to_dict()
        self.assertIn("unsupported asset 0, type", str(context.exception))

        # Test with complex nested structure
        options = nextmv.Options()
        options.duration = 30
        run_stats = nextmv.RunStatistics(duration=10.5, iterations=100)
        result_stats = nextmv.ResultStatistics(value=42.0)
        statistics = nextmv.Statistics(run=run_stats, result=result_stats)
        asset = nextmv.Asset(
            name="asset1",
            content={"data": [1, 2, 3]},
            visual=nextmv.Visual(visual_schema=nextmv.VisualSchema.CHARTJS, label="Test Chart"),
        )
        output = nextmv.Output(
            options=options,
            statistics=statistics,
            assets=[asset],
            solution={"value": 42},
            output_format=nextmv.OutputFormat.JSON,
            json_configurations={"indent": 4},
        )

        result = output.to_dict()
        self.assertEqual(result["options"]["duration"], 30)
        self.assertEqual(result["statistics"]["run"]["duration"], 10.5)
        self.assertEqual(result["statistics"]["result"]["value"], 42.0)
        self.assertEqual(result["assets"][0]["name"], "asset1")
        self.assertEqual(result["assets"][0]["visual"]["schema"], "chartjs")
        self.assertEqual(result["solution"]["value"], 42)

    def test_local_writer_json_stdout_default(self):
        output = nextmv.Output(
            solution={"empanadas": "are_life"},
            statistics={"foo": "bar"},
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "solution": {"empanadas": "are_life"},
                "statistics": {"foo": "bar"},
                "options": {},
                "assets": [],
            }

            self.assertDictEqual(got, expected)

    def test_local_writer_json_stdout_default_dict_output(self):
        output = {
            "solution": {"empanadas": "are_life"},
            "statistics": {"foo": "bar"},
        }
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "solution": {"empanadas": "are_life"},
                "statistics": {"foo": "bar"},
            }

            self.assertDictEqual(got, expected)

    def test_local_writer_json_stdout(self):
        output = nextmv.Output(
            output_format=nextmv.OutputFormat.JSON,
            solution={"empanadas": "are_life"},
            statistics={"foo": "bar"},
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "solution": {"empanadas": "are_life"},
                "statistics": {"foo": "bar"},
                "options": {},
                "assets": [],
            }

            self.assertDictEqual(got, expected)

    def test_local_writer_json_stdout_with_configurations(self):
        output = nextmv.Output(
            output_format=nextmv.OutputFormat.JSON,
            solution={"empanadas": "are_life"},
            statistics={"foo": "bar"},
            json_configurations={
                "indent": None,
                "separators": (",", ":"),
                "sort_keys": True,
            },
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            self.assertEqual(
                mock_stdout.getvalue(),
                '{"assets":[],"options":{},"solution":{"empanadas":"are_life"},"statistics":{"foo":"bar"}}\n',
            )

    def test_local_writer_json_stdout_with_options(self):
        options = nextmv.Options()
        options.duration = 5
        options.solver = "highs"

        output = nextmv.Output(
            options=options,
            output_format=nextmv.OutputFormat.JSON,
            solution={"empanadas": "are_life"},
            statistics={"foo": "bar"},
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "options": {
                    "duration": 5,
                    "solver": "highs",
                },
                "solution": {"empanadas": "are_life"},
                "statistics": {"foo": "bar"},
                "assets": [],
            }

            self.assertDictEqual(got, expected)

    def test_local_writer_json_stdout_with_options_json(self):
        output = nextmv.Output(
            options={"duration": 5, "solver": "highs"},
            output_format=nextmv.OutputFormat.JSON,
            solution={"empanadas": "are_life"},
            statistics={"foo": "bar"},
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "options": {
                    "duration": 5,
                    "solver": "highs",
                },
                "solution": {"empanadas": "are_life"},
                "statistics": {"foo": "bar"},
                "assets": [],
            }

            self.assertDictEqual(got, expected)

    def test_local_writer_json_file(self):
        output = nextmv.Output(
            solution={"empanadas": "are_life"},
            statistics={"foo": "bar"},
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("builtins.open", create=True) as mock_open:
            output_writer.write(output, "output.json")

            handle = mock_open.return_value.__enter__.return_value
            handle.write.assert_called_once()

            got = json.loads(handle.write.call_args[0][0])
            expected = {
                "options": {},
                "solution": {"empanadas": "are_life"},
                "statistics": {"foo": "bar"},
                "assets": [],
            }

            self.assertDictEqual(got, expected)

    def test_local_writer_csvarchive_default_dir(self):
        """If the path for writing an output is not provided, the path `output`
        is used as the default directory."""
        self._test_local_writer_csvarchive(write_path="output", function_path="")

        # Should also work if not provided at all.
        self._test_local_writer_csvarchive(write_path="output", function_path=None)

    def test_local_writer_csvarchive_custom_dir(self):
        """Tests the flow of a CSV archive output writer but with a custom
        directory."""

        write_path = "KrAzYpAtH"
        self._test_local_writer_csvarchive(
            write_path=write_path,
            function_path=write_path,
        )

    def test_local_writer_csvarchive_wrong_path(self):
        output_writer = nextmv.LocalOutputWriter()
        output = nextmv.Output(
            output_format=nextmv.OutputFormat.CSV_ARCHIVE,
        )

        file_name = "a_file_should_not_be_specified.json"
        with open(file_name, "w") as file:
            file.write("")

        # Using a file that already exists should result in an error.
        with self.assertRaises(ValueError):
            # We patch stdout to avoid printing when executing the test.
            with patch("sys.stdout", new=StringIO()) as mock_stdout:
                output_writer.write(output, file_name, skip_stdout_reset=True)
                _ = mock_stdout.getvalue()

        os.remove(file_name)

        # However, using a file name as a directory should not result in an
        # error. It is kind of weird doing that, but to each their own.
        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, file_name, skip_stdout_reset=True)
            _ = mock_stdout.getvalue()

        # Removes the output directory after the test is executed.
        shutil.rmtree(file_name)

    def test_local_writer_csvarchive_dir_overwrite(self):
        output_dir = "empanadas_are_morally_superior_than_pizza"
        os.makedirs(output_dir, exist_ok=True)

        output_writer = nextmv.LocalOutputWriter()
        output = nextmv.Output(
            output_format=nextmv.OutputFormat.CSV_ARCHIVE,
        )

        # We patch stdout to avoid printing when executing the test.
        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, output_dir, skip_stdout_reset=True)
            _ = mock_stdout.getvalue()

        self.assertTrue(os.path.exists(output_dir))

        # Removes the output directory after the test is executed.
        shutil.rmtree(output_dir)

    def test_local_write_bad_output_type(self):
        output = "I am clearly not an output object."
        with self.assertRaises(TypeError):
            nextmv.write(output)

    def test_local_write_passthrough_output(self):
        output = {
            "i_am": "a_crazy_object",
            "with": [
                {"nested": "values"},
                {"and": "more_craziness"},
            ],
        }

        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = output

            self.assertDictEqual(got, expected)

    def test_local_write_base_model(self):
        class myClass(BaseModel):
            output: dict[str, Any]

        output = {
            "i_am": "a_crazy_object",
            "with": [
                {"nested": "values"},
                {"and": "more_craziness"},
            ],
        }
        custom_class = myClass(output=output)

        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(custom_class, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())

            # We test that the `write` method calls the `.to_dict()` method if
            # it detects the output type to be an instance of `BaseModel`.
            expected = {"output": output}

            self.assertDictEqual(got, expected)

    def test_local_write_empty_output(self):
        output = nextmv.Output()

        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = expected = {
                "options": {},
                "solution": {},
                "statistics": {},
                "assets": [],
            }

            self.assertDictEqual(got, expected)

    def test_local_write_valid_assets_from_class(self):
        output = nextmv.Output(
            assets=[
                nextmv.Asset(
                    name="foo",
                    content={"foo": "bar"},
                    content_type="json",
                    description="A foo asset.",
                    visual=nextmv.Visual(
                        visual_schema=nextmv.VisualSchema.CHARTJS,
                        label="A chart",
                        visual_type="custom-tab",
                    ),
                ),
                nextmv.Asset(
                    name="bar",
                    content={"bar": "baz"},
                    content_type="json",
                    description="A bar asset.",
                ),
            ],
        )

        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "options": {},
                "solution": {},
                "statistics": {},
                "assets": [
                    {
                        "content": {"foo": "bar"},
                        "content_type": "json",
                        "description": "A foo asset.",
                        "name": "foo",
                        "visual": {
                            "label": "A chart",
                            "schema": "chartjs",
                            "type": "custom-tab",
                        },
                    },
                    {
                        "content": {"bar": "baz"},
                        "content_type": "json",
                        "description": "A bar asset.",
                        "name": "bar",
                    },
                ],
            }

            self.assertDictEqual(got, expected)

    def test_local_write_valid_assets_from_dict(self):
        assets = [
            {
                "name": "foo",
                "content": {"foo": "bar"},
                "content_type": "json",
                "description": "A foo asset.",
                "visual": {
                    "schema": "chartjs",
                    "label": "A chart",
                    "visual_type": "custom-tab",
                },
            },
            {
                "name": "bar",
                "content": {"bar": "baz"},
                "content_type": "json",
                "description": "A bar asset.",
            },
        ]
        output = nextmv.Output(assets=assets)

        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, skip_stdout_reset=True)

            got = json.loads(mock_stdout.getvalue())
            expected = {
                "options": {},
                "solution": {},
                "statistics": {},
                "assets": assets,
            }

            self.assertDictEqual(got, expected)

    def test_visual_from_dict(self):
        visual_dict = {
            "schema": "chartjs",
            "label": "A chart",
            "type": "custom-tab",
        }

        visual = nextmv.Visual.from_dict(visual_dict)

        self.assertEqual(visual.visual_schema, nextmv.VisualSchema.CHARTJS)
        self.assertEqual(visual.label, "A chart")
        self.assertEqual(visual.visual_type, "custom-tab")

    def test_visual_from_dict_2(self):
        visual_dict = {
            "visual_schema": "chartjs",
            "label": "A chart",
            "visual_type": "custom-tab",
        }

        visual = nextmv.Visual.from_dict(visual_dict)

        self.assertEqual(visual.visual_schema, nextmv.VisualSchema.CHARTJS)
        self.assertEqual(visual.label, "A chart")
        self.assertEqual(visual.visual_type, "custom-tab")

    def test_visual_direct_instantiation(self):
        visual = nextmv.Visual(
            visual_schema=nextmv.VisualSchema.CHARTJS,
            label="A chart",
            visual_type="custom-tab",
        )

        self.assertEqual(visual.visual_schema, nextmv.VisualSchema.CHARTJS)
        self.assertEqual(visual.label, "A chart")
        self.assertEqual(visual.visual_type, "custom-tab")

    def test_visual_direct_instantiation_2(self):
        visual = nextmv.Visual(
            schema=nextmv.VisualSchema.CHARTJS,
            label="A chart",
            type="custom-tab",
        )

        self.assertEqual(visual.visual_schema, nextmv.VisualSchema.CHARTJS)
        self.assertEqual(visual.label, "A chart")
        self.assertEqual(visual.visual_type, "custom-tab")

    def test_visual_to_dict(self):
        visual = nextmv.Visual(
            visual_schema=nextmv.VisualSchema.CHARTJS,
            label="A chart",
            visual_type="custom-tab",
        )

        visual_dict = visual.to_dict()

        self.assertDictEqual(
            visual_dict,
            {
                "schema": "chartjs",
                "label": "A chart",
                "type": "custom-tab",
            },
        )

    def _test_local_writer_csvarchive(
        self,
        write_path: str,
        function_path: str | None = None,
    ) -> None:
        """Auxiliary function that is used to test the flow of a CSV archive
        output output writer but with different directories."""

        options = nextmv.Options()
        options.parse()
        options.duration = 5
        options.solver = "highs"

        solution = {
            "empanadas": [
                {"are": 2.0, "life": 3.0},
                {"are": 5.0, "life": 6.0},
            ],
        }

        output = nextmv.Output(
            options=options,
            output_format=nextmv.OutputFormat.CSV_ARCHIVE,
            solution=solution,
            statistics={"foo": "bar"},
            csv_configurations={"quoting": csv.QUOTE_NONNUMERIC},
        )
        output_writer = nextmv.LocalOutputWriter()

        with patch("sys.stdout", new=StringIO()) as mock_stdout:
            output_writer.write(output, path=function_path, skip_stdout_reset=True)

            stdout_got = json.loads(mock_stdout.getvalue())
            stdout_expected = {
                "options": {
                    "duration": 5,
                    "solver": "highs",
                },
                "statistics": {"foo": "bar"},
                "assets": [],
            }

            self.assertDictEqual(stdout_got, stdout_expected)

        with open(f"{write_path}/empanadas.csv") as file:
            csv_got = file.read()

        csv_expected = '"are","life"\n2.0,3.0\n5.0,6.0\n'

        self.assertEqual(csv_got, csv_expected)

        self.assertTrue(os.path.exists(write_path))

        # Removes the output directory after the test is executed.
        shutil.rmtree(write_path)

    def test_solution_file_creation(self):
        """Test creating SolutionFile instances directly."""

        # Test basic SolutionFile creation
        def simple_writer(file_path: str, data: Any) -> None:
            with open(file_path, "w") as f:
                f.write(str(data))

        solution_file = nextmv.SolutionFile(name="test.txt", data="test data", writer=simple_writer)

        self.assertEqual(solution_file.name, "test.txt")
        self.assertEqual(solution_file.data, "test data")
        self.assertIsNotNone(solution_file.writer)
        self.assertIsNone(solution_file.writer_args)
        self.assertIsNone(solution_file.writer_kwargs)

    def test_solution_file_with_args_kwargs(self):
        """Test SolutionFile with writer arguments and keyword arguments."""

        def writer_with_args(file_path: str, data: Any, *args, **kwargs) -> None:
            with open(file_path, "w") as f:
                f.write(f"{data}-{args}-{kwargs}")

        solution_file = nextmv.SolutionFile(
            name="test.txt",
            data="test data",
            writer=writer_with_args,
            writer_args=["arg1", "arg2"],
            writer_kwargs={"key1": "value1"},
        )

        self.assertEqual(solution_file.writer_args, ["arg1", "arg2"])
        self.assertEqual(solution_file.writer_kwargs, {"key1": "value1"})

    def test_json_solution_file(self):
        """Test json_solution_file convenience function."""

        # Test basic JSON solution file
        data = {"id": 1, "name": "test", "values": [1, 2, 3]}
        solution_file = nextmv.json_solution_file(name="test", data=data)

        self.assertEqual(solution_file.name, "test.json")
        self.assertEqual(solution_file.data, data)
        self.assertIsNotNone(solution_file.writer)

        # Test with .json extension already included
        solution_file = nextmv.json_solution_file(name="test.json", data=data)
        self.assertEqual(solution_file.name, "test.json")

        # Test with JSON configurations
        solution_file = nextmv.json_solution_file(
            name="test", data=data, json_configurations={"indent": 2, "sort_keys": True}
        )
        self.assertEqual(solution_file.name, "test.json")

    def test_csv_solution_file(self):
        """Test csv_solution_file convenience function."""

        # Test basic CSV solution file
        data = [{"id": 1, "name": "Alice", "score": 95}, {"id": 2, "name": "Bob", "score": 87}]
        solution_file = nextmv.csv_solution_file(name="test", data=data)

        self.assertEqual(solution_file.name, "test.csv")
        self.assertEqual(solution_file.data, data)
        self.assertIsNotNone(solution_file.writer)

        # Test with .csv extension already included
        solution_file = nextmv.csv_solution_file(name="test.csv", data=data)
        self.assertEqual(solution_file.name, "test.csv")

        # Test with CSV configurations
        solution_file = nextmv.csv_solution_file(
            name="test", data=data, csv_configurations={"delimiter": ";", "quoting": csv.QUOTE_ALL}
        )
        self.assertEqual(solution_file.name, "test.csv")

    def test_text_solution_file(self):
        """Test text_solution_file convenience function."""

        # Test basic text solution file
        data = "This is a test solution\nwith multiple lines"
        solution_file = nextmv.text_solution_file(name="test.txt", data=data)

        self.assertEqual(solution_file.name, "test.txt")
        self.assertEqual(solution_file.data, data)
        self.assertIsNotNone(solution_file.writer)

    def test_excel_solution_file(self):
        """Test creating custom SolutionFile for Excel files using pandas."""

        # Test Excel solution file (similar to main.py example)
        data = [{"id": 1, "name": "Alice", "score": 95}, {"id": 2, "name": "Bob", "score": 87}]

        solution_file = nextmv.SolutionFile(
            name="test.xlsx",
            data=data,
            writer=lambda file_path, write_data: pd.DataFrame(write_data).to_excel(file_path, index=False),
        )

        self.assertEqual(solution_file.name, "test.xlsx")
        self.assertEqual(solution_file.data, data)
        self.assertIsNotNone(solution_file.writer)

    def test_output_with_solution_files_validation(self):
        """Test Output validation for solution_files."""

        # Test that solution_files requires MULTI_FILE format
        sol_file = nextmv.json_solution_file("test", {"data": "value"})

        # Should raise error when using solution_files with non-MULTI_FILE format
        with self.assertRaises(ValueError) as context:
            nextmv.Output(output_format=nextmv.OutputFormat.JSON, solution_files=[sol_file])
        self.assertIn(
            "solution_files` are not `None`, but `output_format` is different from `OutputFormat.MULTI_FILE`",
            str(context.exception),
        )

        # Should work with MULTI_FILE format
        output = nextmv.Output(output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file])
        self.assertEqual(len(output.solution_files), 1)
        self.assertEqual(output.solution_files[0].name, "test.json")

        # Test invalid solution_files type
        with self.assertRaises(TypeError) as context:
            nextmv.Output(output_format=nextmv.OutputFormat.MULTI_FILE, solution_files="not a list")
        self.assertIn("unsupported Output.solution_files type", str(context.exception))

    def test_local_writer_multi_file_json(self):
        """Test LocalOutputWriter with MULTI_FILE format and JSON solution files."""

        # Create test directory
        test_dir = "test_output_multi_file_json"

        # Clean up any existing test directory
        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            data = {"id": 1, "name": "test", "values": [1, 2, 3]}
            sol_file = nextmv.json_solution_file("solution", data)

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE,
                solution_files=[sol_file],
                statistics={"total_items": 3},
                options={"duration": 5},
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify directory structure
            self.assertTrue(os.path.exists(test_dir))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions")))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions", "solution.json")))

            # Verify solution file content
            with open(os.path.join(test_dir, "solutions", "solution.json")) as f:
                written_data = json.loads(f.read())
                self.assertEqual(written_data, data)

        finally:
            # Clean up
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_csv(self):
        """Test LocalOutputWriter with MULTI_FILE format and CSV solution files."""

        test_dir = "test_output_multi_file_csv"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            data = [{"id": 1, "name": "Alice", "score": 95}, {"id": 2, "name": "Bob", "score": 87}]
            sol_file = nextmv.csv_solution_file("results", data)

            output = nextmv.Output(output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file])

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify file exists
            csv_path = os.path.join(test_dir, "solutions", "results.csv")
            self.assertTrue(os.path.exists(csv_path))

            # Verify CSV content
            with open(csv_path, newline="") as f:
                reader = csv.DictReader(f)
                rows = list(reader)
                self.assertEqual(len(rows), 2)
                self.assertEqual(rows[0]["id"], "1")
                self.assertEqual(rows[0]["name"], "Alice")
                self.assertEqual(rows[1]["name"], "Bob")

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_text(self):
        """Test LocalOutputWriter with MULTI_FILE format and text solution files."""

        test_dir = "test_output_multi_file_text"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            data = "This is a test solution\nwith multiple lines\nof text data"
            sol_file = nextmv.text_solution_file("log.txt", data)

            output = nextmv.Output(output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file])

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify file exists
            text_path = os.path.join(test_dir, "solutions", "log.txt")
            self.assertTrue(os.path.exists(text_path))

            # Verify text content
            with open(text_path) as f:
                content = f.read().strip()  # strip to remove the newline added by writer
                self.assertEqual(content, data)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_excel(self):
        """Test LocalOutputWriter with MULTI_FILE format and Excel solution files."""

        test_dir = "test_output_multi_file_excel"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            data = [{"id": 1, "name": "Alice", "score": 95}, {"id": 2, "name": "Bob", "score": 87}]

            sol_file = nextmv.SolutionFile(
                name="data.xlsx",
                data=data,
                writer=lambda file_path, write_data: pd.DataFrame(write_data).to_excel(file_path, index=False),
            )

            output = nextmv.Output(output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file])

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify file exists
            excel_path = os.path.join(test_dir, "solutions", "data.xlsx")
            self.assertTrue(os.path.exists(excel_path))

            # Verify Excel content
            df = pd.read_excel(excel_path)
            self.assertEqual(len(df), 2)
            self.assertEqual(df.iloc[0]["name"], "Alice")
            self.assertEqual(df.iloc[1]["name"], "Bob")

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_multiple_solution_files(self):
        """Test LocalOutputWriter with multiple solution files of different types."""

        test_dir = "test_output_multi_file_multiple"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create different types of solution files
            json_data = {"summary": "test results", "count": 2}
            csv_data = [{"id": 1, "value": 100}, {"id": 2, "value": 200}]
            text_data = "Log: Process completed successfully"

            sol_files = [
                nextmv.json_solution_file("summary", json_data),
                nextmv.csv_solution_file("data", csv_data),
                nextmv.text_solution_file("log.txt", text_data),
            ]

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=sol_files, statistics={"files_created": 3}
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify all files exist
            solutions_dir = os.path.join(test_dir, "solutions")
            self.assertTrue(os.path.exists(os.path.join(solutions_dir, "summary.json")))
            self.assertTrue(os.path.exists(os.path.join(solutions_dir, "data.csv")))
            self.assertTrue(os.path.exists(os.path.join(solutions_dir, "log.txt")))

            # Verify content of each file
            with open(os.path.join(solutions_dir, "summary.json")) as f:
                json_content = json.loads(f.read())
                self.assertEqual(json_content, json_data)

            with open(os.path.join(solutions_dir, "data.csv"), newline="") as f:
                reader = csv.DictReader(f)
                csv_content = list(reader)
                self.assertEqual(len(csv_content), 2)
                self.assertEqual(csv_content[0]["id"], "1")

            with open(os.path.join(solutions_dir, "log.txt")) as f:
                text_content = f.read().strip()
                self.assertEqual(text_content, text_data)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_solution_file_writer_error_handling(self):
        """Test error handling in solution file writing."""

        test_dir = "test_output_error_handling"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Test with invalid solution file type
            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=["not a SolutionFile object"]
            )

            output_writer = nextmv.LocalOutputWriter()

            with self.assertRaises(TypeError) as context:
                output_writer.write(output, path=test_dir)

            self.assertIn("unsupported solution_file type", str(context.exception))

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_statistics(self):
        """Test LocalOutputWriter with MULTI_FILE format writes statistics to statistics/statistics.json."""

        test_dir = "test_output_multi_file_statistics"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create basic solution file
            sol_file = nextmv.json_solution_file("solution", {"result": "success"})

            # Create statistics
            run_stats = nextmv.RunStatistics(duration=15.5, iterations=100)
            result_stats = nextmv.ResultStatistics(value=42.0)
            statistics = nextmv.Statistics(run=run_stats, result=result_stats)

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file], statistics=statistics
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify statistics file exists
            stats_path = os.path.join(test_dir, "statistics", "statistics.json")
            self.assertTrue(os.path.exists(stats_path))

            # Verify statistics content
            with open(stats_path) as f:
                stats_content = json.loads(f.read())
                self.assertEqual(stats_content["statistics"]["run"]["duration"], 15.5)
                self.assertEqual(stats_content["statistics"]["run"]["iterations"], 100)
                self.assertEqual(stats_content["statistics"]["result"]["value"], 42.0)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_statistics_dict(self):
        """Test LocalOutputWriter with MULTI_FILE format writes dictionary statistics to statistics/statistics.json."""

        test_dir = "test_output_multi_file_statistics_dict"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create basic solution file
            sol_file = nextmv.text_solution_file("log.txt", "Process completed")

            # Create dictionary statistics
            statistics = {"custom_metric": 123.45, "total_processed": 1000, "success_rate": 0.95}

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file], statistics=statistics
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify statistics file exists
            stats_path = os.path.join(test_dir, "statistics", "statistics.json")
            self.assertTrue(os.path.exists(stats_path))

            # Verify statistics content
            with open(stats_path) as f:
                stats_content = json.loads(f.read())
                self.assertEqual(stats_content["statistics"]["custom_metric"], 123.45)
                self.assertEqual(stats_content["statistics"]["total_processed"], 1000)
                self.assertEqual(stats_content["statistics"]["success_rate"], 0.95)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_assets(self):
        """Test LocalOutputWriter with MULTI_FILE format writes assets to assets/assets.json."""

        test_dir = "test_output_multi_file_assets"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create basic solution file
            sol_file = nextmv.csv_solution_file("data", [{"id": 1, "value": "test"}])

            # Create assets
            assets = [
                nextmv.Asset(
                    name="chart_data",
                    content={"type": "bar", "data": [1, 2, 3, 4, 5]},
                    content_type="json",
                    description="Chart visualization data",
                    visual=nextmv.Visual(
                        visual_schema=nextmv.VisualSchema.CHARTJS, label="Performance Chart", visual_type="chart"
                    ),
                ),
                nextmv.Asset(
                    name="summary_table",
                    content={"headers": ["Metric", "Value"], "rows": [["Total", 100], ["Success", 95]]},
                    content_type="json",
                    description="Summary table data",
                ),
            ]

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file], assets=assets
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify assets file exists
            assets_path = os.path.join(test_dir, "assets", "assets.json")
            self.assertTrue(os.path.exists(assets_path))

            # Verify assets content
            with open(assets_path) as f:
                assets_content = json.loads(f.read())
                self.assertEqual(len(assets_content["assets"]), 2)

                # Check first asset
                asset1 = assets_content["assets"][0]
                self.assertEqual(asset1["name"], "chart_data")
                self.assertEqual(asset1["content"]["type"], "bar")
                self.assertEqual(asset1["description"], "Chart visualization data")
                self.assertEqual(asset1["visual"]["schema"], "chartjs")
                self.assertEqual(asset1["visual"]["label"], "Performance Chart")

                # Check second asset
                asset2 = assets_content["assets"][1]
                self.assertEqual(asset2["name"], "summary_table")
                self.assertEqual(asset2["content"]["headers"], ["Metric", "Value"])
                self.assertNotIn("visual", asset2)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_assets_dict(self):
        """Test LocalOutputWriter with MULTI_FILE format writes dictionary assets to assets/assets.json."""

        test_dir = "test_output_multi_file_assets_dict"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create basic solution file
            sol_file = nextmv.json_solution_file("result", {"status": "complete"})

            # Create dictionary assets
            assets = [
                {
                    "name": "performance_metrics",
                    "content": {"cpu_usage": 45.2, "memory_usage": 67.8, "disk_io": 12.3},
                    "content_type": "json",
                    "description": "System performance metrics",
                },
                {
                    "name": "optimization_trace",
                    "content": {"iterations": [1, 2, 3], "objective_values": [100, 85, 70]},
                    "content_type": "json",
                    "description": "Optimization algorithm trace",
                    "visual": {"schema": "chartjs", "label": "Convergence Plot", "type": "line-chart"},
                },
            ]

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[sol_file], assets=assets
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify assets file exists
            assets_path = os.path.join(test_dir, "assets", "assets.json")
            self.assertTrue(os.path.exists(assets_path))

            # Verify assets content
            with open(assets_path) as f:
                assets_content = json.loads(f.read())
                self.assertEqual(len(assets_content["assets"]), 2)

                # Check first asset
                asset1 = assets_content["assets"][0]
                self.assertEqual(asset1["name"], "performance_metrics")
                self.assertEqual(asset1["content"]["cpu_usage"], 45.2)
                self.assertEqual(asset1["description"], "System performance metrics")

                # Check second asset with visual
                asset2 = assets_content["assets"][1]
                self.assertEqual(asset2["name"], "optimization_trace")
                self.assertEqual(asset2["visual"]["schema"], "chartjs")
                self.assertEqual(asset2["visual"]["label"], "Convergence Plot")

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_complete(self):
        """Test LocalOutputWriter with MULTI_FILE format writes solutions, statistics, and assets to
        correct directories."""

        test_dir = "test_output_multi_file_complete"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create multiple solution files
            json_data = {"optimization_result": {"objective": 150.5, "variables": {"x": 10, "y": 15}}}
            csv_data = [{"route_id": 1, "distance": 45.2, "time": 60}, {"route_id": 2, "distance": 38.7, "time": 50}]
            text_data = "Optimization completed successfully\nTotal time: 120 seconds\nFinal objective: 150.5"

            sol_files = [
                nextmv.json_solution_file("optimization_result", json_data),
                nextmv.csv_solution_file("routes", csv_data),
                nextmv.text_solution_file("summary.log", text_data),
            ]

            # Create comprehensive statistics
            run_stats = nextmv.RunStatistics(duration=120.0, iterations=500)
            result_stats = nextmv.ResultStatistics(value=150.5)
            statistics = nextmv.Statistics(run=run_stats, result=result_stats)

            # Create comprehensive assets
            assets = [
                nextmv.Asset(
                    name="route_visualization",
                    content={"routes": [{"points": [[0, 0], [10, 5], [15, 10]], "color": "blue"}]},
                    content_type="json",
                    description="Route visualization data",
                    visual=nextmv.Visual(
                        visual_schema=nextmv.VisualSchema.CHARTJS, label="Route Map", visual_type="map"
                    ),
                ),
                nextmv.Asset(
                    name="convergence_data",
                    content={
                        "iterations": list(range(1, 501)),
                        "objective_values": [200 - i * 0.1 for i in range(500)],
                    },
                    content_type="json",
                    description="Algorithm convergence data",
                ),
            ]

            # Create options
            options = nextmv.Options()
            options.duration = 120
            options.solver = "custom_optimizer"

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE,
                solution_files=sol_files,
                statistics=statistics,
                assets=assets,
                options=options,
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify directory structure
            self.assertTrue(os.path.exists(test_dir))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions")))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "statistics")))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "assets")))

            # Verify solution files
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions", "optimization_result.json")))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions", "routes.csv")))
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions", "summary.log")))

            # Verify statistics file
            stats_path = os.path.join(test_dir, "statistics", "statistics.json")
            self.assertTrue(os.path.exists(stats_path))
            with open(stats_path) as f:
                stats_content = json.loads(f.read())
                self.assertEqual(stats_content["statistics"]["run"]["duration"], 120.0)
                self.assertEqual(stats_content["statistics"]["run"]["iterations"], 500)
                self.assertEqual(stats_content["statistics"]["result"]["value"], 150.5)

            # Verify assets file
            assets_path = os.path.join(test_dir, "assets", "assets.json")
            self.assertTrue(os.path.exists(assets_path))
            with open(assets_path) as f:
                assets_content = json.loads(f.read())
                self.assertEqual(len(assets_content["assets"]), 2)
                self.assertEqual(assets_content["assets"][0]["name"], "route_visualization")
                self.assertEqual(assets_content["assets"][0]["visual"]["schema"], "chartjs")
                self.assertEqual(assets_content["assets"][1]["name"], "convergence_data")

            # Verify solution file contents
            with open(os.path.join(test_dir, "solutions", "optimization_result.json")) as f:
                json_content = json.loads(f.read())
                self.assertEqual(json_content["optimization_result"]["objective"], 150.5)

            with open(os.path.join(test_dir, "solutions", "routes.csv"), newline="") as f:
                reader = csv.DictReader(f)
                csv_content = list(reader)
                self.assertEqual(len(csv_content), 2)
                self.assertEqual(csv_content[0]["route_id"], "1")
                self.assertEqual(csv_content[0]["distance"], "45.2")

            with open(os.path.join(test_dir, "solutions", "summary.log")) as f:
                text_content = f.read().strip()
                self.assertIn("Optimization completed successfully", text_content)
                self.assertIn("Final objective: 150.5", text_content)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_local_writer_multi_file_empty_statistics_assets(self):
        """Test LocalOutputWriter with MULTI_FILE format handles empty statistics and assets correctly."""

        test_dir = "test_output_multi_file_empty"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Create basic solution file
            sol_file = nextmv.json_solution_file("basic", {"result": "test"})

            output = nextmv.Output(
                output_format=nextmv.OutputFormat.MULTI_FILE,
                solution_files=[sol_file],
                statistics={},  # Empty statistics
                assets=[],  # Empty assets
            )

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify only solutions directory exists (empty statistics and assets shouldn't create directories)
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions")))
            self.assertFalse(os.path.exists(os.path.join(test_dir, "statistics")))
            self.assertFalse(os.path.exists(os.path.join(test_dir, "assets")))

            # Verify solution file exists
            self.assertTrue(os.path.exists(os.path.join(test_dir, "solutions", "basic.json")))

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)

    def test_solution_file_configurations(self):
        """Test solution files with various configurations."""

        test_dir = "test_output_configurations"

        if os.path.exists(test_dir):
            shutil.rmtree(test_dir)

        try:
            # Test JSON with specific configurations
            json_data = {"name": "test", "values": [3, 1, 2]}
            json_sol = nextmv.json_solution_file(
                "config_test", json_data, json_configurations={"indent": 4, "sort_keys": True}
            )

            # Test CSV with specific configurations
            csv_data = [{"name": "Alice", "city": "New York"}, {"name": "Bob", "city": "Los Angeles"}]
            csv_sol = nextmv.csv_solution_file(
                "config_test", csv_data, csv_configurations={"delimiter": "|", "quoting": csv.QUOTE_ALL}
            )

            output = nextmv.Output(output_format=nextmv.OutputFormat.MULTI_FILE, solution_files=[json_sol, csv_sol])

            output_writer = nextmv.LocalOutputWriter()
            output_writer.write(output, path=test_dir)

            # Verify JSON formatting
            with open(os.path.join(test_dir, "solutions", "config_test.json")) as f:
                content = f.read()
                # Should be indented and sorted
                self.assertIn("    ", content)  # Check for indentation
                # Check that keys are sorted (name comes before values)
                name_pos = content.find('"name"')
                values_pos = content.find('"values"')
                self.assertLess(name_pos, values_pos)

            # Verify CSV formatting with custom delimiter
            with open(os.path.join(test_dir, "solutions", "config_test.csv")) as f:
                content = f.read()
                self.assertIn("|", content)  # Check for custom delimiter
                self.assertIn('"Alice"', content)  # Check for quotes (QUOTE_ALL)

        finally:
            if os.path.exists(test_dir):
                shutil.rmtree(test_dir)
