"""Tests for the XML-preserving ELAN round-trip engine."""

from __future__ import annotations

import os
import time
from pathlib import Path
from xml.etree import ElementTree as ET

import pytest

from sltk.io.elan_roundtrip import ElanDocument, SegmentInfo, TierInfo

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

SAMPLE_EAF = """\
<?xml version="1.0" encoding="UTF-8"?>
<ANNOTATION_DOCUMENT AUTHOR="test" DATE="2026-01-01T00:00:00+00:00"
    FORMAT="3.0" VERSION="3.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="http://www.mpi.nl/tools/elan/EAFv3.0.xsd">
    <HEADER MEDIA_FILE="" TIME_UNITS="milliseconds">
        <MEDIA_DESCRIPTOR MEDIA_URL="file:///video.mp4" MIME_TYPE="video/mp4"/>
    </HEADER>
    <TIME_ORDER>
        <TIME_SLOT TIME_SLOT_ID="ts1" TIME_VALUE="0"/>
        <TIME_SLOT TIME_SLOT_ID="ts2" TIME_VALUE="1500"/>
        <TIME_SLOT TIME_SLOT_ID="ts3" TIME_VALUE="3000"/>
        <TIME_SLOT TIME_SLOT_ID="ts4" TIME_VALUE="4500"/>
    </TIME_ORDER>
    <TIER LINGUISTIC_TYPE_REF="default-lt" TIER_ID="Gloss" PARTICIPANT="signer1">
        <ANNOTATION>
            <ALIGNABLE_ANNOTATION ANNOTATION_ID="a1"
                TIME_SLOT_REF1="ts1" TIME_SLOT_REF2="ts2">
                <ANNOTATION_VALUE>HELLO</ANNOTATION_VALUE>
            </ALIGNABLE_ANNOTATION>
        </ANNOTATION>
        <ANNOTATION>
            <ALIGNABLE_ANNOTATION ANNOTATION_ID="a2"
                TIME_SLOT_REF1="ts2" TIME_SLOT_REF2="ts3">
                <ANNOTATION_VALUE>WORLD</ANNOTATION_VALUE>
            </ALIGNABLE_ANNOTATION>
        </ANNOTATION>
    </TIER>
    <TIER LINGUISTIC_TYPE_REF="default-lt" TIER_ID="Translation">
        <ANNOTATION>
            <ALIGNABLE_ANNOTATION ANNOTATION_ID="a3"
                TIME_SLOT_REF1="ts1" TIME_SLOT_REF2="ts4">
                <ANNOTATION_VALUE>Hello world</ANNOTATION_VALUE>
            </ALIGNABLE_ANNOTATION>
        </ANNOTATION>
    </TIER>
    <LINGUISTIC_TYPE LINGUISTIC_TYPE_ID="default-lt"
        TIME_ALIGNABLE="true" GRAPHIC_REFERENCES="false"/>
    <CONSTRAINT DESCRIPTION="only for referring types"
        STEREOTYPE="Symbolic_Association"/>
    <CONTROLLED_VOCABULARY CV_ID="test_cv">
        <CV_ENTRY_ML CVE_ID="cve1">
            <CVE_VALUE LANG_REF="en" DESCRIPTION="test">value1</CVE_VALUE>
        </CV_ENTRY_ML>
    </CONTROLLED_VOCABULARY>
</ANNOTATION_DOCUMENT>
"""


@pytest.fixture
def sample_eaf(tmp_path: Path) -> Path:
    """Write the sample EAF to a temp file and return its path."""
    p = tmp_path / "test.eaf"
    p.write_text(SAMPLE_EAF, encoding="utf-8")
    return p


@pytest.fixture
def doc(sample_eaf: Path) -> ElanDocument:
    return ElanDocument.open(sample_eaf)


# ---------------------------------------------------------------------------
# Open / basic reading
# ---------------------------------------------------------------------------


class TestOpen:
    def test_open_parses_time_slots(self, doc: ElanDocument) -> None:
        assert doc._time_slots["ts1"] == 0
        assert doc._time_slots["ts2"] == 1500
        assert doc._time_slots["ts3"] == 3000
        assert doc._time_slots["ts4"] == 4500

    def test_open_next_ts_id(self, doc: ElanDocument) -> None:
        assert doc._next_ts_id == 5

    def test_open_next_ann_id(self, doc: ElanDocument) -> None:
        assert doc._next_ann_id == 4

    def test_open_missing_file(self, tmp_path: Path) -> None:
        with pytest.raises(Exception):
            ElanDocument.open(tmp_path / "nope.eaf")


# ---------------------------------------------------------------------------
# get_tiers / get_segments
# ---------------------------------------------------------------------------


class TestGetTiers:
    def test_returns_all_tiers(self, doc: ElanDocument) -> None:
        tiers = doc.get_tiers()
        assert len(tiers) == 2
        names = [t.name for t in tiers]
        assert "Gloss" in names
        assert "Translation" in names

    def test_tier_metadata(self, doc: ElanDocument) -> None:
        gloss = [t for t in doc.get_tiers() if t.name == "Gloss"][0]
        assert gloss.linguistic_type == "default-lt"
        assert gloss.participant == "signer1"
        assert gloss.segment_count == 2


class TestGetSegments:
    def test_all_segments(self, doc: ElanDocument) -> None:
        segs = doc.get_segments()
        assert len(segs) == 3

    def test_filter_by_tier(self, doc: ElanDocument) -> None:
        segs = doc.get_segments(tiers=["Gloss"])
        assert len(segs) == 2
        assert all(s.tier == "Gloss" for s in segs)

    def test_time_conversion(self, doc: ElanDocument) -> None:
        segs = doc.get_segments(tiers=["Gloss"])
        hello = [s for s in segs if s.label == "HELLO"][0]
        assert hello.start == 0.0
        assert hello.end == 1.5

    def test_annotation_ids(self, doc: ElanDocument) -> None:
        segs = doc.get_segments()
        ids = {s.annotation_id for s in segs}
        assert ids == {"a1", "a2", "a3"}


# ---------------------------------------------------------------------------
# Round-trip fidelity
# ---------------------------------------------------------------------------


class TestRoundTrip:
    def test_save_and_reopen_preserves_segments(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "rt.eaf"
        doc.save(out)
        doc2 = ElanDocument.open(out)
        assert doc2.get_segments() == doc.get_segments()

    def test_preserves_controlled_vocabulary(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "rt.eaf"
        doc.save(out)
        tree = ET.parse(out)
        cvs = tree.findall(".//CONTROLLED_VOCABULARY")
        assert len(cvs) == 1
        assert cvs[0].get("CV_ID") == "test_cv"

    def test_preserves_constraints(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "rt.eaf"
        doc.save(out)
        tree = ET.parse(out)
        constraints = tree.findall(".//CONSTRAINT")
        assert len(constraints) == 1
        assert constraints[0].get("STEREOTYPE") == "Symbolic_Association"

    def test_preserves_header_media(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "rt.eaf"
        doc.save(out)
        tree = ET.parse(out)
        md = tree.findall(".//MEDIA_DESCRIPTOR")
        assert len(md) == 1
        assert md[0].get("MEDIA_URL") == "file:///video.mp4"

    def test_modify_and_roundtrip(self, doc: ElanDocument, tmp_path: Path) -> None:
        """Modify a segment, save, reopen, verify change AND that
        everything else is still intact."""
        doc.update_segment("a1", label="HI")
        out = tmp_path / "rt.eaf"
        doc.save(out)

        doc2 = ElanDocument.open(out)
        segs = doc2.get_segments(tiers=["Gloss"])
        hi = [s for s in segs if s.annotation_id == "a1"][0]
        assert hi.label == "HI"

        # Controlled vocab still there
        tree = ET.parse(out)
        assert len(tree.findall(".//CONTROLLED_VOCABULARY")) == 1

    def test_xml_declaration_present(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "rt.eaf"
        doc.save(out)
        text = out.read_text(encoding="utf-8")
        assert text.startswith("<?xml")


# ---------------------------------------------------------------------------
# Create from scratch
# ---------------------------------------------------------------------------


class TestNew:
    def test_new_creates_valid_doc(self) -> None:
        doc = ElanDocument.new()
        tiers = doc.get_tiers()
        assert tiers == []
        segs = doc.get_segments()
        assert segs == []

    def test_new_with_video(self, tmp_path: Path) -> None:
        vid = tmp_path / "video.mp4"
        vid.touch()
        doc = ElanDocument.new(video_path=vid)
        out = tmp_path / "new.eaf"
        doc.save(out)

        tree = ET.parse(out)
        md = tree.findall(".//MEDIA_DESCRIPTOR")
        assert len(md) == 1
        assert "video.mp4" in md[0].get("MEDIA_URL", "")

    def test_new_add_tier_and_segment(self, tmp_path: Path) -> None:
        doc = ElanDocument.new()
        doc.add_tier("Gloss")
        aid = doc.add_segment("Gloss", 0.0, 1.5, "HELLO")
        assert aid == "a1"

        out = tmp_path / "scratch.eaf"
        doc.save(out)

        doc2 = ElanDocument.open(out)
        segs = doc2.get_segments()
        assert len(segs) == 1
        assert segs[0].label == "HELLO"
        assert segs[0].start == 0.0
        assert segs[0].end == 1.5

    def test_new_no_path_save_requires_path(self) -> None:
        doc = ElanDocument.new()
        with pytest.raises(ValueError, match="No path"):
            doc.save()


# ---------------------------------------------------------------------------
# update_segment
# ---------------------------------------------------------------------------


class TestUpdateSegment:
    def test_update_label(self, doc: ElanDocument) -> None:
        doc.update_segment("a1", label="HI")
        segs = doc.get_segments(tiers=["Gloss"])
        labels = {s.annotation_id: s.label for s in segs}
        assert labels["a1"] == "HI"

    def test_update_start(self, doc: ElanDocument) -> None:
        doc.update_segment("a1", start=0.5)
        segs = doc.get_segments(tiers=["Gloss"])
        s = [x for x in segs if x.annotation_id == "a1"][0]
        assert s.start == 0.5

    def test_update_end(self, doc: ElanDocument) -> None:
        doc.update_segment("a1", end=2.0)
        segs = doc.get_segments(tiers=["Gloss"])
        s = [x for x in segs if x.annotation_id == "a1"][0]
        assert s.end == 2.0

    def test_update_nonexistent(self, doc: ElanDocument) -> None:
        with pytest.raises(KeyError):
            doc.update_segment("a999", label="X")


# ---------------------------------------------------------------------------
# add_segment
# ---------------------------------------------------------------------------


class TestAddSegment:
    def test_basic_add(self, doc: ElanDocument) -> None:
        aid = doc.add_segment("Gloss", 5.0, 6.0, "NEW")
        assert aid == "a4"  # max existing is a3
        segs = doc.get_segments(tiers=["Gloss"])
        new_seg = [s for s in segs if s.annotation_id == aid][0]
        assert new_seg.label == "NEW"
        assert new_seg.start == 5.0
        assert new_seg.end == 6.0

    def test_time_slot_reuse(self, doc: ElanDocument) -> None:
        """Adding a segment at t=1.5s should reuse ts2 (1500ms)."""
        initial_ts_count = len(doc._time_slots)
        doc.add_segment("Gloss", 1.5, 6.0, "REUSE")
        # Only one new time slot should be created (6000ms)
        assert len(doc._time_slots) == initial_ts_count + 1

    def test_add_to_nonexistent_tier(self, doc: ElanDocument) -> None:
        with pytest.raises(KeyError):
            doc.add_segment("NoSuchTier", 0.0, 1.0, "X")


# ---------------------------------------------------------------------------
# delete_segment
# ---------------------------------------------------------------------------


class TestDeleteSegment:
    def test_basic_delete(self, doc: ElanDocument) -> None:
        doc.delete_segment("a1")
        segs = doc.get_segments()
        ids = {s.annotation_id for s in segs}
        assert "a1" not in ids

    def test_orphaned_time_slot_cleanup(self, doc: ElanDocument) -> None:
        """ts1 (0ms) is only used by a1 and a3.
        Deleting a1: ts1 still used by a3 -> keep.
        Deleting a3: ts1 now orphaned -> remove.
        ts4 (4500ms) is only used by a3 -> remove after a3 deletion.
        """
        doc.delete_segment("a3")
        # ts4 should be gone (only a3 used it)
        assert "ts4" not in doc._time_slots
        # ts1 should remain (a1 still uses it)
        assert "ts1" in doc._time_slots

    def test_shared_time_slot_not_removed(self, doc: ElanDocument) -> None:
        """ts2 (1500ms) is shared by a1 and a2. Deleting a1 keeps ts2."""
        doc.delete_segment("a1")
        assert "ts2" in doc._time_slots

    def test_delete_nonexistent(self, doc: ElanDocument) -> None:
        with pytest.raises(KeyError):
            doc.delete_segment("a999")


# ---------------------------------------------------------------------------
# Tier operations
# ---------------------------------------------------------------------------


class TestTierOperations:
    def test_add_tier(self, doc: ElanDocument) -> None:
        doc.add_tier("NewTier")
        names = [t.name for t in doc.get_tiers()]
        assert "NewTier" in names

    def test_add_duplicate_tier(self, doc: ElanDocument) -> None:
        with pytest.raises(ValueError, match="already exists"):
            doc.add_tier("Gloss")

    def test_add_tier_creates_linguistic_type(self, doc: ElanDocument, tmp_path: Path) -> None:
        doc.add_tier("Custom", linguistic_type="custom-lt")
        out = tmp_path / "lt.eaf"
        doc.save(out)
        tree = ET.parse(out)
        lt_ids = [lt.get("LINGUISTIC_TYPE_ID") for lt in tree.findall(".//LINGUISTIC_TYPE")]
        assert "custom-lt" in lt_ids

    def test_rename_tier(self, doc: ElanDocument) -> None:
        doc.rename_tier("Gloss", "Signs")
        names = [t.name for t in doc.get_tiers()]
        assert "Signs" in names
        assert "Gloss" not in names

    def test_rename_nonexistent_tier(self, doc: ElanDocument) -> None:
        with pytest.raises(KeyError):
            doc.rename_tier("NoSuch", "X")

    def test_delete_tier(self, doc: ElanDocument) -> None:
        doc.delete_tier("Translation")
        names = [t.name for t in doc.get_tiers()]
        assert "Translation" not in names
        # Segments from deleted tier should be gone
        segs = doc.get_segments()
        assert all(s.tier != "Translation" for s in segs)

    def test_delete_tier_cleans_orphaned_slots(self, doc: ElanDocument) -> None:
        """ts4 (4500ms) is only used by Translation tier's a3."""
        doc.delete_tier("Translation")
        assert "ts4" not in doc._time_slots

    def test_delete_nonexistent_tier(self, doc: ElanDocument) -> None:
        with pytest.raises(KeyError):
            doc.delete_tier("NoSuch")

    def test_reorder_tiers(self, doc: ElanDocument) -> None:
        doc.reorder_tiers(["Translation", "Gloss"])
        tiers = doc.get_tiers()
        assert tiers[0].name == "Translation"
        assert tiers[1].name == "Gloss"

    def test_reorder_preserves_unlisted(self, doc: ElanDocument) -> None:
        doc.add_tier("Extra")
        doc.reorder_tiers(["Translation"])
        names = [t.name for t in doc.get_tiers()]
        assert names[0] == "Translation"
        # Gloss and Extra should follow in original order
        assert set(names) == {"Translation", "Gloss", "Extra"}


# ---------------------------------------------------------------------------
# Conflict detection
# ---------------------------------------------------------------------------


class TestConflictDetection:
    def test_no_conflict_after_open(self, doc: ElanDocument) -> None:
        assert doc.check_disk_conflict() is False

    def test_conflict_after_external_modification(self, sample_eaf: Path) -> None:
        doc = ElanDocument.open(sample_eaf)
        # Ensure mtime changes (some filesystems have 1s granularity)
        time.sleep(0.05)
        sample_eaf.write_text(
            SAMPLE_EAF.replace("HELLO", "MODIFIED"),
            encoding="utf-8",
        )
        assert doc.check_disk_conflict() is True

    def test_no_conflict_for_new_doc(self) -> None:
        doc = ElanDocument.new()
        assert doc.check_disk_conflict() is False

    def test_conflict_when_file_deleted(self, sample_eaf: Path) -> None:
        doc = ElanDocument.open(sample_eaf)
        sample_eaf.unlink()
        assert doc.check_disk_conflict() is True

    def test_no_conflict_after_own_save(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "saved.eaf"
        doc.save(out)
        assert doc.check_disk_conflict() is False


# ---------------------------------------------------------------------------
# Save / atomic write
# ---------------------------------------------------------------------------


class TestSave:
    def test_save_to_explicit_path(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "explicit.eaf"
        result = doc.save(out)
        assert result == out
        assert out.exists()

    def test_save_overwrites_original(self, sample_eaf: Path) -> None:
        doc = ElanDocument.open(sample_eaf)
        doc.update_segment("a1", label="CHANGED")
        doc.save()
        doc2 = ElanDocument.open(sample_eaf)
        s = [x for x in doc2.get_segments() if x.annotation_id == "a1"][0]
        assert s.label == "CHANGED"

    def test_auto_version(self, doc: ElanDocument, tmp_path: Path) -> None:
        base = tmp_path / "base.eaf"
        doc.save(base)
        # Save again with auto_version — should create base_v2.eaf
        doc.save(base, auto_version=True)
        assert (tmp_path / "base_v2.eaf").exists()

    def test_auto_version_increments(self, doc: ElanDocument, tmp_path: Path) -> None:
        base = tmp_path / "base.eaf"
        doc.save(base)
        (tmp_path / "base_v2.eaf").touch()
        doc.save(base, auto_version=True)
        assert (tmp_path / "base_v3.eaf").exists()

    def test_atomic_write_no_temp_on_success(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "atomic.eaf"
        doc.save(out)
        # No .tmp files should remain
        tmps = list(tmp_path.glob("*.eaf.tmp"))
        assert tmps == []

    def test_creates_parent_dirs(self, doc: ElanDocument, tmp_path: Path) -> None:
        out = tmp_path / "sub" / "dir" / "file.eaf"
        doc.save(out)
        assert out.exists()


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------


class TestEdgeCases:
    def test_empty_document(self, tmp_path: Path) -> None:
        """A doc with TIME_ORDER but no tiers or annotations."""
        xml = (
            '<?xml version="1.0" encoding="UTF-8"?>'
            "<ANNOTATION_DOCUMENT>"
            '<HEADER MEDIA_FILE="" TIME_UNITS="milliseconds"/>'
            "<TIME_ORDER/>"
            '<LINGUISTIC_TYPE LINGUISTIC_TYPE_ID="default-lt" '
            'TIME_ALIGNABLE="true"/>'
            "</ANNOTATION_DOCUMENT>"
        )
        p = tmp_path / "empty.eaf"
        p.write_text(xml, encoding="utf-8")
        doc = ElanDocument.open(p)
        assert doc.get_tiers() == []
        assert doc.get_segments() == []

    def test_single_segment(self, tmp_path: Path) -> None:
        doc = ElanDocument.new()
        doc.add_tier("T")
        doc.add_segment("T", 0.0, 1.0, "ONLY")
        out = tmp_path / "single.eaf"
        doc.save(out)
        doc2 = ElanDocument.open(out)
        segs = doc2.get_segments()
        assert len(segs) == 1
        assert segs[0].label == "ONLY"

    def test_overlapping_segments(self, tmp_path: Path) -> None:
        doc = ElanDocument.new()
        doc.add_tier("T")
        doc.add_segment("T", 0.0, 2.0, "A")
        doc.add_segment("T", 1.0, 3.0, "B")
        out = tmp_path / "overlap.eaf"
        doc.save(out)
        doc2 = ElanDocument.open(out)
        segs = doc2.get_segments()
        assert len(segs) == 2

    def test_empty_label(self, tmp_path: Path) -> None:
        doc = ElanDocument.new()
        doc.add_tier("T")
        doc.add_segment("T", 0.0, 1.0, "")
        segs = doc.get_segments()
        assert segs[0].label == ""

    def test_unicode_labels(self, tmp_path: Path) -> None:
        doc = ElanDocument.new()
        doc.add_tier("T")
        doc.add_segment("T", 0.0, 1.0, "Geb\u00e4rde \u2705")
        out = tmp_path / "uni.eaf"
        doc.save(out)
        doc2 = ElanDocument.open(out)
        assert doc2.get_segments()[0].label == "Geb\u00e4rde \u2705"

    def test_many_segments_id_tracking(self) -> None:
        doc = ElanDocument.new()
        doc.add_tier("T")
        ids = set()
        for i in range(100):
            aid = doc.add_segment("T", float(i), float(i + 1), f"s{i}")
            ids.add(aid)
        assert len(ids) == 100  # All unique

    def test_delete_all_segments_in_tier(self, doc: ElanDocument) -> None:
        """Delete every segment in Gloss; tier should remain."""
        doc.delete_segment("a1")
        doc.delete_segment("a2")
        tiers = doc.get_tiers()
        gloss = [t for t in tiers if t.name == "Gloss"][0]
        assert gloss.segment_count == 0

    def test_update_time_cleans_old_orphan(self, doc: ElanDocument) -> None:
        """Changing a1's start from 0ms to 500ms should orphan ts1
        only if a3 doesn't also reference it."""
        # a3 also refs ts1, so ts1 should survive
        doc.update_segment("a1", start=0.5)
        assert "ts1" in doc._time_slots  # a3 still uses it

        # Now delete a3, then the original ts1 is orphaned only via
        # the remaining annotation. Since a1 no longer uses ts1, check:
        doc.delete_segment("a3")
        # ts1 should now be gone (no one references it)
        assert "ts1" not in doc._time_slots

    def test_ms_rounding(self) -> None:
        """Verify sub-ms precision is handled via rounding."""
        doc = ElanDocument.new()
        doc.add_tier("T")
        doc.add_segment("T", 1.2345, 2.6789, "R")
        segs = doc.get_segments()
        # round(1.2345 * 1000) = 1234ms -> 1.234s
        assert segs[0].start == pytest.approx(1.234, abs=0.001)
        assert segs[0].end == pytest.approx(2.679, abs=0.001)


# ---------------------------------------------------------------------------
# Dataclass return types
# ---------------------------------------------------------------------------


class TestDataclasses:
    def test_tier_info_fields(self, doc: ElanDocument) -> None:
        tier = doc.get_tiers()[0]
        assert isinstance(tier, TierInfo)
        assert isinstance(tier.name, str)
        assert isinstance(tier.segment_count, int)

    def test_segment_info_fields(self, doc: ElanDocument) -> None:
        seg = doc.get_segments()[0]
        assert isinstance(seg, SegmentInfo)
        assert isinstance(seg.annotation_id, str)
        assert isinstance(seg.start, float)
        assert isinstance(seg.end, float)

    def test_frozen_dataclasses(self, doc: ElanDocument) -> None:
        tier = doc.get_tiers()[0]
        with pytest.raises(AttributeError):
            tier.name = "X"  # type: ignore[misc]
        seg = doc.get_segments()[0]
        with pytest.raises(AttributeError):
            seg.label = "X"  # type: ignore[misc]
