"""Tests for output format generators (SVG, DXF, JSON)."""

from __future__ import annotations

import json

import io

import ezdxf
import pytest

from pipeline.postprocess import PathInfo, PostProcessResult, postprocess_svg
from output.svg import generate_svg
from output.json_output import generate_json
from output.dxf import generate_dxf


def _read_dxf(dxf_bytes: bytes) -> ezdxf.document.Drawing:
    """Read DXF bytes back into an ezdxf document (needs StringIO, not BytesIO)."""
    return ezdxf.read(io.StringIO(dxf_bytes.decode("utf-8")))


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

SIMPLE_SVG = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
    '<path d="M 10,10 L 90,10 L 90,90 L 10,90 Z" fill="black"/>'
    "</svg>"
)

# SVG with an outer CCW rectangle and an inner CW rectangle (island/hole)
SVG_WITH_ISLAND = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
    '<path d="M 0,0 L 100,0 L 100,100 L 0,100 Z" fill="black"/>'
    '<path d="M 30,30 L 30,70 L 70,70 L 70,30 Z" fill="black"/>'
    "</svg>"
)

# SVG with an open path (no Z close)
SVG_OPEN_PATH = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
    '<path d="M 10,10 L 50,10 L 50,50" fill="none" stroke="black"/>'
    "</svg>"
)


def _make_result(svg: str = SIMPLE_SVG, epsilon: float = 0.1) -> PostProcessResult:
    """Run postprocess pipeline and return result for testing output generators."""
    return postprocess_svg(svg, epsilon=epsilon)


# ---------------------------------------------------------------------------
# SVG output tests
# ---------------------------------------------------------------------------


class TestSVGOutput:
    def test_returns_string(self):
        result = _make_result()
        svg = generate_svg(result)
        assert isinstance(svg, str)

    def test_contains_svg_root(self):
        result = _make_result()
        svg = generate_svg(result)
        assert "<svg" in svg
        assert "</svg>" in svg

    def test_contains_path_element(self):
        result = _make_result()
        svg = generate_svg(result)
        assert "<path" in svg

    def test_valid_xml(self):
        import xml.etree.ElementTree as ET

        result = _make_result()
        svg = generate_svg(result)
        root = ET.fromstring(svg)
        assert root.tag == "{http://www.w3.org/2000/svg}svg"


# ---------------------------------------------------------------------------
# JSON output tests
# ---------------------------------------------------------------------------


class TestJSONOutput:
    def test_returns_valid_json(self):
        result = _make_result()
        output = generate_json(result)
        parsed = json.loads(output)
        assert isinstance(parsed, dict)

    def test_has_paths_and_metadata(self):
        result = _make_result()
        parsed = json.loads(generate_json(result))
        assert "paths" in parsed
        assert "metadata" in parsed

    def test_path_has_commands_and_properties(self):
        result = _make_result()
        parsed = json.loads(generate_json(result))
        assert len(parsed["paths"]) > 0
        path = parsed["paths"][0]
        assert "commands" in path
        assert "properties" in path

    def test_commands_have_correct_types(self):
        result = _make_result()
        parsed = json.loads(generate_json(result))
        path = parsed["paths"][0]
        types = [cmd["type"] for cmd in path["commands"]]
        assert types[0] == "M"  # starts with Move
        assert "L" in types  # has line commands
        assert types[-1] == "Z"  # closed path ends with Z

    def test_properties_fields(self):
        result = _make_result()
        parsed = json.loads(generate_json(result))
        props = parsed["paths"][0]["properties"]
        assert "is_closed" in props
        assert "is_island" in props
        assert "node_count" in props
        assert "original_node_count" in props
        assert "area" in props

    def test_metadata_fields(self):
        result = _make_result()
        parsed = json.loads(generate_json(result))
        meta = parsed["metadata"]
        assert "path_count" in meta
        assert "total_nodes" in meta
        assert "total_original_nodes" in meta
        assert "open_path_count" in meta
        assert "island_count" in meta

    def test_metadata_path_count_matches(self):
        result = _make_result()
        parsed = json.loads(generate_json(result))
        assert parsed["metadata"]["path_count"] == len(parsed["paths"])

    def test_island_flagged_in_properties(self):
        result = _make_result(SVG_WITH_ISLAND)
        parsed = json.loads(generate_json(result))
        island_flags = [p["properties"]["is_island"] for p in parsed["paths"]]
        assert True in island_flags  # at least one island detected

    def test_open_path_not_closed(self):
        result = _make_result(SVG_OPEN_PATH)
        parsed = json.loads(generate_json(result))
        # Open path should not end with Z
        path = parsed["paths"][0]
        types = [cmd["type"] for cmd in path["commands"]]
        assert types[-1] != "Z"


# ---------------------------------------------------------------------------
# DXF output tests
# ---------------------------------------------------------------------------


class TestDXFOutput:
    def test_returns_bytes(self):
        result = _make_result()
        dxf = generate_dxf(result)
        assert isinstance(dxf, bytes)

    def test_valid_dxf_structure(self):
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        # ezdxf can read it back
        doc = _read_dxf(dxf_bytes)
        assert doc.dxfversion >= "AC1015"

    def test_contains_lwpolyline_entities(self):
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        assert len(polylines) > 0

    def test_polyline_has_correct_point_count(self):
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        # Simple square: 4 unique points (close flag handles the fifth)
        total_points = sum(len(list(pl.get_points())) for pl in polylines)
        assert total_points >= 4

    def test_closed_path_produces_closed_polyline(self):
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        # At least one closed polyline
        assert any(pl.is_closed for pl in polylines)

    def test_island_on_islands_layer(self):
        result = _make_result(SVG_WITH_ISLAND)
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        layers = [pl.dxf.layer for pl in polylines]
        assert "ISLANDS" in layers

    def test_outer_contour_on_default_layer(self):
        result = _make_result(SVG_WITH_ISLAND)
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        layers = [pl.dxf.layer for pl in polylines]
        assert "0" in layers

    def test_ac1015_version(self):
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        assert doc.dxfversion == "AC1015"

    def test_dxf_header_present(self):
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        # Raw bytes should contain DXF section markers
        text = dxf_bytes.decode("ascii", errors="replace")
        assert "HEADER" in text
        assert "ENTITIES" in text

    def test_open_path_produces_open_polyline(self):
        result = _make_result(SVG_OPEN_PATH)
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        # Open path should produce non-closed polyline
        assert any(not pl.is_closed for pl in polylines)


# ---------------------------------------------------------------------------
# DXF unit/scale/layer_map tests
# ---------------------------------------------------------------------------


# 384×576 px artboard = 4×6 inches at 96 PPI
_ARTBOARD_SVG_4x6 = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="384" height="576" viewBox="0 0 384 576">'
    '<path d="M 0,0 L 384,0 L 384,576 L 0,576 Z" fill="black"/>'
    "</svg>"
)


class TestDXFUnitsAndScale:
    """Tests for the units, scale_factor, and layer_map extensions."""

    def test_scale_factor_converts_pixel_coords_to_inches(self):
        """A 384×576 px rectangle scaled by 1/96 should span 0–4 × 0–6 inches."""
        result = _make_result(_ARTBOARD_SVG_4x6)
        scale = 1.0 / 96.0
        dxf_bytes = generate_dxf(result, units="inches", scale_factor=scale)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        assert len(polylines) >= 1

        # Collect all x/y values across polylines
        all_x: list[float] = []
        all_y: list[float] = []
        for pl in polylines:
            for pt in pl.get_points():
                all_x.append(pt[0])
                all_y.append(pt[1])

        assert min(all_x) == pytest.approx(0.0, abs=0.01)
        assert max(all_x) == pytest.approx(4.0, abs=0.01)
        assert min(all_y) == pytest.approx(0.0, abs=0.01)
        assert max(all_y) == pytest.approx(6.0, abs=0.01)

    def test_scale_factor_converts_pixel_coords_to_mm(self):
        """A 384×576 px rectangle scaled by 25.4/96 should span 0–101.6 × 0–152.4 mm."""
        result = _make_result(_ARTBOARD_SVG_4x6)
        scale = 25.4 / 96.0
        dxf_bytes = generate_dxf(result, units="mm", scale_factor=scale)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))

        all_x: list[float] = []
        all_y: list[float] = []
        for pl in polylines:
            for pt in pl.get_points():
                all_x.append(pt[0])
                all_y.append(pt[1])

        assert max(all_x) == pytest.approx(101.6, abs=0.1)
        assert max(all_y) == pytest.approx(152.4, abs=0.1)

    def test_insunits_header_set_for_inches(self):
        """$INSUNITS should be 1 (inches) when units='inches'."""
        result = _make_result()
        dxf_bytes = generate_dxf(result, units="inches")
        doc = _read_dxf(dxf_bytes)
        assert doc.header["$INSUNITS"] == 1

    def test_insunits_header_set_for_mm(self):
        """$INSUNITS should be 4 (millimeters) when units='mm'."""
        result = _make_result()
        dxf_bytes = generate_dxf(result, units="mm")
        doc = _read_dxf(dxf_bytes)
        assert doc.header["$INSUNITS"] == 4

    def test_measurement_header_imperial(self):
        """$MEASUREMENT should be 0 (imperial) when units='inches'."""
        result = _make_result()
        dxf_bytes = generate_dxf(result, units="inches")
        doc = _read_dxf(dxf_bytes)
        assert doc.header["$MEASUREMENT"] == 0

    def test_measurement_header_metric(self):
        """$MEASUREMENT should be 1 (metric) when units='mm'."""
        result = _make_result()
        dxf_bytes = generate_dxf(result, units="mm")
        doc = _read_dxf(dxf_bytes)
        assert doc.header["$MEASUREMENT"] == 1

    def test_no_units_omits_insunits_header(self):
        """When no units specified, $INSUNITS stays at the ezdxf R2000 default (6 = meters)."""
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        # ezdxf R2000 template defaults $INSUNITS to 6 (meters) — we don't override it
        assert doc.header["$INSUNITS"] == 6

    def test_scale_factor_default_no_change(self):
        """With scale_factor=1.0 (default), coords remain unchanged from pixel values."""
        result = _make_result()
        dxf_bytes = generate_dxf(result)
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        # Original SIMPLE_SVG has coords around 10–90
        all_x: list[float] = []
        for pl in polylines:
            for pt in pl.get_points():
                all_x.append(pt[0])
        assert max(all_x) == pytest.approx(90.0, abs=1.0)

    def test_layer_map_assigns_paths_to_named_layers(self):
        """layer_map should place specific paths on custom layers."""
        result = _make_result(SVG_WITH_ISLAND)
        # Assign path 0 to "CUT" layer
        dxf_bytes = generate_dxf(result, layer_map={0: "CUT"})
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        layers = [pl.dxf.layer for pl in polylines]
        assert "CUT" in layers

    def test_layer_map_does_not_override_island_layer(self):
        """Islands should still go to ISLANDS layer even when layer_map is used,
        unless the layer_map explicitly assigns them elsewhere.
        """
        result = _make_result(SVG_WITH_ISLAND)
        # Only assign path 0 to "CUT", leave other paths to default logic
        # Find which index is the island
        island_idx = next(i for i, p in enumerate(result.paths) if p.is_island)
        non_island_idx = next(i for i, p in enumerate(result.paths) if not p.is_island)
        dxf_bytes = generate_dxf(result, layer_map={non_island_idx: "CUT"})
        doc = _read_dxf(dxf_bytes)
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        layers = [pl.dxf.layer for pl in polylines]
        assert "CUT" in layers
        assert "ISLANDS" in layers

    def test_combined_units_scale_and_layer_map(self):
        """All three features work together: units + scale + layer_map."""
        result = _make_result(_ARTBOARD_SVG_4x6)
        scale = 1.0 / 96.0
        dxf_bytes = generate_dxf(
            result, units="inches", scale_factor=scale, layer_map={0: "OUTLINE"}
        )
        doc = _read_dxf(dxf_bytes)

        # Headers set
        assert doc.header["$INSUNITS"] == 1
        assert doc.header["$MEASUREMENT"] == 0

        # Scale applied
        msp = doc.modelspace()
        polylines = list(msp.query("LWPOLYLINE"))
        all_x: list[float] = []
        for pl in polylines:
            for pt in pl.get_points():
                all_x.append(pt[0])
        assert max(all_x) == pytest.approx(4.0, abs=0.01)

        # Layer assigned
        layers = [pl.dxf.layer for pl in polylines]
        assert "OUTLINE" in layers


# ---------------------------------------------------------------------------
# Integration: round-trip through all formats
# ---------------------------------------------------------------------------


class TestRoundTrip:
    def test_all_formats_from_same_input(self):
        """All three generators produce valid output from the same PostProcessResult."""
        result = _make_result()
        svg = generate_svg(result)
        json_out = generate_json(result)
        dxf = generate_dxf(result)

        assert isinstance(svg, str) and "<svg" in svg
        assert isinstance(json_out, str) and json.loads(json_out)
        assert isinstance(dxf, bytes) and len(dxf) > 0

    def test_path_count_consistent_across_formats(self):
        """JSON and DXF should have the same number of paths as the result."""
        result = _make_result(SVG_WITH_ISLAND)
        parsed_json = json.loads(generate_json(result))

        doc = _read_dxf(generate_dxf(result))
        msp = doc.modelspace()
        dxf_polylines = list(msp.query("LWPOLYLINE"))

        assert parsed_json["metadata"]["path_count"] == len(result.paths)
        assert len(dxf_polylines) == len(result.paths)
