"""Tests for Click TagMap logical-to-hardware mapping."""

from __future__ import annotations

from typing import Any, cast

import pyclickplc
import pytest
from pyclickplc.addresses import AddressRecord, get_addr_key
from pyclickplc.banks import DataType

from pyrung.click import TagMap, c, ds, x
from pyrung.core import Block, Bool, Tag, TagType


def test_resolve_standalone_tag():
    valve = Bool("Valve")
    mapping = TagMap({valve: c[1]})

    assert mapping.resolve(valve) == "C1"
    assert mapping.resolve("Valve") == "C1"


def test_resolve_block_slot():
    alarms = Block("Alarm", TagType.BOOL, 1, 3)
    mapping = TagMap({alarms: c.select(101, 103)})

    assert mapping.resolve(alarms, 1) == "C101"
    assert mapping.resolve(alarms, 3) == "C103"


def test_resolve_block_slot_sparse_bank():
    alarms = Block("Alarm", TagType.BOOL, 1, 17)
    mapping = TagMap({alarms: x.select(1, 21)})

    assert mapping.resolve(alarms, 17) == "X021"


def test_offset_for_block():
    alarms = Block("Alarm", TagType.BOOL, 1, 3)
    mapping = TagMap({alarms: c.select(101, 103)})

    assert mapping.offset_for(alarms) == 100


def test_offset_for_sparse_block_raises():
    alarms = Block("Alarm", TagType.BOOL, 1, 17)
    mapping = TagMap({alarms: x.select(1, 21)})

    with pytest.raises(ValueError, match="affine"):
        mapping.offset_for(alarms)


def test_map_to_syntax():
    valve = Bool("Valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    mapping = TagMap([valve.map_to(c[1]), alarms.map_to(c.select(101, 102))])

    assert mapping.resolve("Valve") == "C1"
    assert mapping.resolve(alarms, 2) == "C102"


def test_dict_constructor():
    valve = Bool("Valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    mapping = TagMap({valve: c[1], alarms: c.select(101, 102)})

    assert mapping.resolve("Valve") == "C1"
    assert mapping.resolve(alarms, 1) == "C101"


def test_empty_map():
    mapping = TagMap(include_system=False)

    assert len(mapping) == 0
    assert mapping.tags() == ()
    assert mapping.blocks() == ()
    assert mapping.entries == ()
    assert mapping.mapped_slots() == ()


def test_contains_tag_name_and_object():
    valve = Bool("Valve")
    mapping = TagMap({valve: c[1]})

    assert "Valve" in mapping
    assert valve in mapping
    assert Bool("Valve") in mapping


def test_mapped_slots_include_standalone_and_block_slots():
    valve = Bool("Valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    mapping = TagMap({valve: c[1], alarms: x.select(1, 2)}, include_system=False)

    slots = mapping.mapped_slots()
    assert len(slots) == 3

    assert slots[0].hardware_address == "C1"
    assert slots[0].memory_type == "C"
    assert slots[0].address == 1
    assert slots[0].logical_name == "Valve"
    assert slots[0].default is False

    assert slots[1].hardware_address == "X001"
    assert slots[1].memory_type == "X"
    assert slots[1].address == 1
    assert slots[1].logical_name == "Alarm1"

    assert slots[2].hardware_address == "X002"
    assert slots[2].memory_type == "X"
    assert slots[2].address == 2
    assert slots[2].logical_name == "Alarm2"


def test_mapped_slots_use_canonical_default_metadata():
    value = Tag("Value", TagType.INT, default=5)
    mapping = TagMap({value: ds[1]}, include_system=False)

    slots = mapping.mapped_slots()
    assert len(slots) == 1
    assert slots[0].default == 5


def test_contains_block():
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    mapping = TagMap({alarms: c.select(101, 102)})

    assert alarms in mapping


def test_len_counts_entries_not_slots():
    valve = Bool("Valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 20)
    mapping = TagMap({valve: c[1], alarms: c.select(101, 120)})

    assert len(mapping) == 2


def test_type_mismatch_tag_raises():
    value = Tag("Value", TagType.INT)

    with pytest.raises(ValueError, match="Type mismatch"):
        TagMap({value: c[1]})


def test_type_mismatch_block_raises():
    values = Block("Values", TagType.INT, 1, 2)

    with pytest.raises(ValueError, match="Type mismatch"):
        TagMap({values: c.select(101, 102)})


def test_block_size_mismatch_raises():
    alarms = Block("Alarm", TagType.BOOL, 1, 20)

    with pytest.raises(ValueError, match="Block size mismatch"):
        TagMap({alarms: x.select(1, 21)})


def test_address_conflict_tag_tag_raises():
    valve = Bool("Valve")
    pump = Bool("Pump")

    with pytest.raises(ValueError, match="Hardware address conflict"):
        TagMap({valve: c[1], pump: c[1]})


def test_address_conflict_tag_block_raises():
    valve = Bool("Valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 3)

    with pytest.raises(ValueError, match="Hardware address conflict"):
        TagMap({valve: c[101], alarms: c.select(100, 102)})


def test_address_conflict_block_block_raises():
    alarm_a = Block("AlarmA", TagType.BOOL, 1, 3)
    alarm_b = Block("AlarmB", TagType.BOOL, 1, 3)

    with pytest.raises(ValueError, match="Hardware address conflict"):
        TagMap({alarm_a: c.select(100, 102), alarm_b: c.select(102, 104)})


def test_name_conflict_standalone_tags_raises():
    first = Bool("Valve")
    second = Bool("Valve")

    with pytest.raises(ValueError, match="Duplicate user logical tag name"):
        TagMap([first.map_to(c[1]), second.map_to(c[2])])


def test_name_conflict_standalone_and_block_slot_raises():
    valve = Bool("Alarm1")
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    with pytest.raises(ValueError, match="Duplicate user logical tag name"):
        TagMap({valve: c[1], alarms: c.select(101, 102)})


def test_name_conflict_across_blocks_raises():
    alarm_a = Block("AlarmA", TagType.BOOL, 1, 1)
    alarm_b = Block("AlarmB", TagType.BOOL, 1, 1)
    alarm_a.slot(1, name="Shared")
    alarm_b.slot(1, name="Shared")

    with pytest.raises(ValueError, match="Duplicate user logical tag name"):
        TagMap({alarm_a: c.select(101, 101), alarm_b: c.select(201, 201)})


def test_name_conflict_from_block_name_ending_in_digit_has_specific_hint():
    template = Block("Template.Subroutines", TagType.BOOL, 26, 26)
    template_2 = Block("Template.Subroutines2", TagType.BOOL, 6, 6)

    with pytest.raises(ValueError, match="does not end in a number"):
        TagMap({template: c.select(101, 101), template_2: c.select(201, 201)})


def test_block_slot_name_rename_exported_to_csv(tmp_path):
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    alarms.slot(1, name="Alarm_1")
    mapping = TagMap({alarms: c.select(101, 102)})
    path = tmp_path / "renamed.csv"
    mapping.to_nickname_file(path)
    rows = pyclickplc.read_csv(path)
    assert rows[get_addr_key("C", 101)].nickname == "Alarm_1"


def test_nickname_validation_warnings_standalone():
    bad = Bool("Bad-Name")
    mapping = TagMap({bad: c[1]})

    assert mapping.warnings
    assert "invalid" in mapping.warnings[0]


def test_nickname_validation_warnings_block_slots():
    bad_block = Block("Bad-", TagType.BOOL, 1, 2)
    mapping = TagMap({bad_block: c.select(101, 102)})

    assert mapping.warnings


def test_nickname_validation_warns_leading_underscore():
    mapping = TagMap({Bool("_Bad"): c[1]})

    assert mapping.warnings
    assert "Cannot start with _" in mapping.warnings[0]


def test_to_nickname_file_sparse_only_mapped_rows(tmp_path):
    valve = Bool("Valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    mapping = TagMap({valve: c[1], alarms: c.select(101, 102)})

    path = tmp_path / "mapped.csv"
    count = mapping.to_nickname_file(path)
    rows = pyclickplc.read_csv(path)

    assert count == 3
    assert len(rows) == 3
    assert get_addr_key("C", 1) in rows
    assert get_addr_key("C", 101) in rows
    assert get_addr_key("C", 102) in rows


def test_to_nickname_file_uses_first_class_slot_name_and_runtime_policy(tmp_path):
    alarms = Block("Alarm", TagType.BOOL, 1, 1, retentive=False)
    alarms.slot(1, name="Alarm_1", retentive=True, default=True, comment="First alarm")
    mapping = TagMap({alarms: c.select(101, 101)})

    path = tmp_path / "slot_metadata.csv"
    mapping.to_nickname_file(path)
    rows = pyclickplc.read_csv(path)
    record = rows[get_addr_key("C", 101)]

    assert record.nickname == "Alarm_1"
    assert record.retentive is True
    assert record.initial_value == "1"
    assert record.comment == "<Alarm:block /> First alarm"


def test_to_nickname_file_uses_first_class_slot_runtime_policy(tmp_path):
    alarms = Block("AlarmCfg", TagType.BOOL, 1, 1, retentive=False)
    alarms.slot(1, retentive=True, default=True)
    mapping = TagMap({alarms: c.select(301, 301)})

    path = tmp_path / "slot_runtime.csv"
    mapping.to_nickname_file(path)
    rows = pyclickplc.read_csv(path)
    record = rows[get_addr_key("C", 301)]

    assert record.retentive is True
    assert record.initial_value == "1"


def test_from_nickname_file_round_trip(tmp_path):
    valve = Bool("Valve", comment="Main valve")
    alarms = Block("Alarm", TagType.BOOL, 1, 2)
    alarms.slot(1, name="Alarm_1", default=True, comment="First alarm")
    alarms.slot(2, comment="Last alarm")
    mapping = TagMap({valve: c[1], alarms: c.select(101, 102)})

    path = tmp_path / "round_trip.csv"
    mapping.to_nickname_file(path)
    restored = TagMap.from_nickname_file(path)

    assert restored.resolve("Valve") == "C1"
    assert restored.tags()[0].logical.comment == "Main valve"
    restored_block = next(block_entry.logical for block_entry in restored.blocks())
    assert restored.resolve(restored_block, 1) == "C101"
    assert restored_block[1].name == "Alarm_1"
    assert restored_block[1].default is True
    assert restored_block[1].comment == "First alarm"
    assert restored_block[2].comment == "Last alarm"


def test_from_nickname_file_hydrates_block_slot_runtime_policy(tmp_path):
    path = tmp_path / "slot_runtime_import.csv"
    records = {
        get_addr_key("C", 401): AddressRecord(
            memory_type="C",
            address=401,
            nickname="AlarmCfg1",
            comment="<AlarmCfg:block>",
            initial_value="1",
            retentive=True,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 402): AddressRecord(
            memory_type="C",
            address=402,
            nickname="AlarmCfg2",
            comment="</AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    block = restored.blocks()[0].logical
    assert block[1].retentive is True
    assert block[1].default is True
    assert block[2].retentive is False
    assert block[2].default is False


def test_from_nickname_file_sparse_block_rows_preserve_full_span(tmp_path):
    path = tmp_path / "sparse.csv"
    records = {
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1",
            comment="<Alarm:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 200): AddressRecord(
            memory_type="C",
            address=200,
            nickname="Alarm100",
            comment="</Alarm:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert len(restored.blocks()) == 1

    block = restored.blocks()[0].logical
    assert block.start == 1
    assert block.end == 100
    assert restored.resolve(block, 1) == "C101"
    assert restored.resolve(block, 100) == "C200"


def test_from_nickname_file_rejects_blank_block_nickname(tmp_path):
    path = tmp_path / "marker_only_boundary.csv"
    records = {
        get_addr_key("C", 401): AddressRecord(
            memory_type="C",
            address=401,
            nickname="AlarmCfg1",
            comment="<AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 402): AddressRecord(
            memory_type="C",
            address=402,
            nickname="",
            comment="</AlarmCfg:block>",
            initial_value="",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    block = restored.blocks()[0].logical

    assert restored.resolve(block, 1) == "C401"
    assert restored.resolve(block, 2) == "C402"
    assert block[1].name == "AlarmCfg1"
    assert block[2].name == "AlarmCfg2"


def test_from_nickname_file_allows_blank_block_nickname_when_boundary_has_extra_metadata(tmp_path):
    path = tmp_path / "blank_block_name.csv"
    records = {
        get_addr_key("C", 401): AddressRecord(
            memory_type="C",
            address=401,
            nickname="AlarmCfg1",
            comment="<AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 402): AddressRecord(
            memory_type="C",
            address=402,
            nickname="",
            comment="</AlarmCfg:block> Boundary note",
            initial_value="1",
            retentive=True,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    block = restored.blocks()[0].logical

    assert restored.resolve(block, 2) == "C402"
    assert block[2].name == "AlarmCfg2"
    assert block[2].comment == "Boundary note"
    assert block[2].default is True
    assert block[2].retentive is True


def test_from_nickname_file_allows_blank_block_nickname_for_internal_metadata_row(tmp_path):
    path = tmp_path / "blank_internal_block_name.csv"
    records = {
        get_addr_key("C", 501): AddressRecord(
            memory_type="C",
            address=501,
            nickname="AlarmCfg1",
            comment="<AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 502): AddressRecord(
            memory_type="C",
            address=502,
            nickname="",
            comment="Held state",
            initial_value="1",
            retentive=True,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 503): AddressRecord(
            memory_type="C",
            address=503,
            nickname="AlarmCfg3",
            comment="</AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    block = restored.blocks()[0].logical

    assert restored.resolve(block, 2) == "C502"
    assert block[2].name == "AlarmCfg2"
    assert block[2].comment == "Held state"
    assert block[2].default is True
    assert block[2].retentive is True


def test_from_nickname_file_rejects_invalid_block_nickname(tmp_path):
    path = tmp_path / "invalid_block_name.csv"
    records = {
        get_addr_key("C", 501): AddressRecord(
            memory_type="C",
            address=501,
            nickname="Bad-Name",
            comment="<AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 502): AddressRecord(
            memory_type="C",
            address=502,
            nickname="AlarmCfg2",
            comment="</AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="C501"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_rejects_duplicate_block_nickname(tmp_path):
    path = tmp_path / "duplicate_block_name.csv"
    records = {
        get_addr_key("C", 601): AddressRecord(
            memory_type="C",
            address=601,
            nickname="AlarmCfg",
            comment="<AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 602): AddressRecord(
            memory_type="C",
            address=602,
            nickname="AlarmCfg",
            comment="</AlarmCfg:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="C602"):
        TagMap.from_nickname_file(path)


def test_block_entry_by_name_found():
    alarms = Block("Alarm", TagType.BOOL, 1, 3)
    mapping = TagMap({alarms: c.select(101, 103)})

    entry = mapping.block_entry_by_name("Alarm")
    assert entry is not None
    assert entry.logical is alarms


def test_block_entry_by_name_not_found():
    mapping = TagMap(include_system=False)

    assert mapping.block_entry_by_name("NoSuchBlock") is None


def test_from_nickname_file_udt_grouping_success(tmp_path):
    path = tmp_path / "udt_success.csv"
    records = {
        get_addr_key("DS", 1001): AddressRecord(
            memory_type="DS",
            address=1001,
            nickname="Alarm1_id",
            comment="<Alarm.id:udt>",
            initial_value="1",
            retentive=True,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 1002): AddressRecord(
            memory_type="DS",
            address=1002,
            nickname="Alarm2_id",
            comment="</Alarm.id:udt>",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1_On",
            comment="<Alarm.On:udt>",
            initial_value="1",
            retentive=True,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 102): AddressRecord(
            memory_type="C",
            address=102,
            nickname="Alarm2_On",
            comment="</Alarm.On:udt>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert len(restored.structures) == 1
    struct = restored.structures[0]
    assert struct.kind == "udt"
    assert struct.name == "Alarm"
    assert struct.count == 2
    assert struct.stride is None
    assert restored.structure_by_name("Alarm") == struct
    assert restored.structure_warnings == ()

    runtime = cast(Any, struct.runtime)
    assert restored.resolve(runtime[2].id) == "DS1002"
    assert restored.resolve(runtime[1].On) == "C101"


def test_from_nickname_file_udt_grouping_count_mismatch_falls_back(tmp_path):
    path = tmp_path / "udt_fallback.csv"
    records = {
        get_addr_key("DS", 1001): AddressRecord(
            memory_type="DS",
            address=1001,
            nickname="Alarm1_id",
            comment="<Alarm.id:udt>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 1002): AddressRecord(
            memory_type="DS",
            address=1002,
            nickname="Alarm2_id",
            comment="</Alarm.id:udt>",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1_On",
            comment="<Alarm.On:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert restored.structures == ()
    assert restored.structure_by_name("Alarm") is None
    assert restored.structure_warnings
    assert "Alarm" in restored.structure_warnings[0]
    assert restored.block_entry_by_name("Alarm.id") is not None
    assert restored.block_entry_by_name("Alarm.On") is not None


def test_from_nickname_file_udt_grouping_count_mismatch_fails_in_strict_mode(tmp_path):
    path = tmp_path / "udt_fallback_fail.csv"
    records = {
        get_addr_key("DS", 1001): AddressRecord(
            memory_type="DS",
            address=1001,
            nickname="Alarm1_id",
            comment="<Alarm.id:udt>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 1002): AddressRecord(
            memory_type="DS",
            address=1002,
            nickname="Alarm2_id",
            comment="</Alarm.id:udt>",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1_On",
            comment="<Alarm.On:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="UDT grouping failed for base 'Alarm'"):
        TagMap.from_nickname_file(path, mode="strict")


def test_from_nickname_file_udt_grouping_success_in_strict_mode(tmp_path):
    path = tmp_path / "udt_success_strict_mode.csv"
    records = {
        get_addr_key("DS", 1001): AddressRecord(
            memory_type="DS",
            address=1001,
            nickname="Alarm1_id",
            comment="<Alarm.id:udt>",
            initial_value="1",
            retentive=True,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 1002): AddressRecord(
            memory_type="DS",
            address=1002,
            nickname="Alarm2_id",
            comment="</Alarm.id:udt>",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1_On",
            comment="<Alarm.On:udt>",
            initial_value="1",
            retentive=True,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 102): AddressRecord(
            memory_type="C",
            address=102,
            nickname="Alarm2_On",
            comment="</Alarm.On:udt>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path, mode="strict")
    assert len(restored.structures) == 1
    assert restored.structures[0].kind == "udt"
    assert restored.structure_warnings == ()


def test_from_nickname_file_named_array_success(tmp_path):
    path = tmp_path / "named_array_success.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="AlarmPacked1_id",
            comment="<AlarmPacked:named_array(2,3)>",
            initial_value="1",
            retentive=True,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="AlarmPacked1_val",
            comment="",
            initial_value="10",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 503): AddressRecord(
            memory_type="DS",
            address=503,
            nickname="",
            comment="",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 504): AddressRecord(
            memory_type="DS",
            address=504,
            nickname="AlarmPacked2_id",
            comment="",
            initial_value="2",
            retentive=True,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 505): AddressRecord(
            memory_type="DS",
            address=505,
            nickname="AlarmPacked2_val",
            comment="",
            initial_value="20",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 506): AddressRecord(
            memory_type="DS",
            address=506,
            nickname="",
            comment="</AlarmPacked:named_array(2,3)>",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert len(restored.structures) == 1
    struct = restored.structures[0]
    assert struct.kind == "named_array"
    assert struct.name == "AlarmPacked"
    assert struct.count == 2
    assert struct.stride == 3
    assert restored.structure_warnings == ()

    runtime = cast(Any, struct.runtime)
    assert restored.resolve(runtime[1].id) == "DS501"
    assert restored.resolve(runtime[2].id) == "DS504"
    assert restored.resolve(runtime[2].val) == "DS505"


def test_from_nickname_file_named_array_sparse_nicknames_accepted(tmp_path):
    """Sparse nicknames are fine — only populated rows are registered."""
    path = tmp_path / "named_array_sparse.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="AlarmPacked1_id",
            comment="<AlarmPacked:named_array(2,3)>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="AlarmPacked1_val",
            comment="",
            initial_value="10",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 504): AddressRecord(
            memory_type="DS",
            address=504,
            nickname="AlarmPacked2_id",
            comment="",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 506): AddressRecord(
            memory_type="DS",
            address=506,
            nickname="",
            comment="</AlarmPacked:named_array(2,3)>",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    mapping = TagMap.from_nickname_file(path)
    struct = mapping.structure_by_name("AlarmPacked")
    assert struct is not None
    runtime = cast(Any, struct.runtime)
    assert mapping.resolve(runtime[1].id) == "DS501"
    assert mapping.resolve(runtime[2].id) == "DS504"


def test_from_nickname_file_named_array_bad_nickname_pattern_raises(tmp_path):
    path = tmp_path / "named_array_bad_pattern.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="AlarmPacked1-id",
            comment="<AlarmPacked:named_array(1,1) />",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        )
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="invalid nickname"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_named_array_inconsistent_offset_raises(tmp_path):
    path = tmp_path / "named_array_bad_offset.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="AlarmPacked1_id",
            comment="<AlarmPacked:named_array(2,3)>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="AlarmPacked1_val",
            comment="",
            initial_value="10",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 504): AddressRecord(
            memory_type="DS",
            address=504,
            nickname="AlarmPacked2_val",
            comment="",
            initial_value="20",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 505): AddressRecord(
            memory_type="DS",
            address=505,
            nickname="AlarmPacked2_id",
            comment="",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 506): AddressRecord(
            memory_type="DS",
            address=506,
            nickname="",
            comment="</AlarmPacked:named_array(2,3)>",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="appears at offset"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_named_array_count_one_compact_names_infer_no_always_number(tmp_path):
    """count=1 with compact nicknames (Task_call) → always_number=False."""
    path = tmp_path / "na_compact.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Task_call",
            comment="<Task:named_array(1,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="Task_done",
            comment="</Task:named_array(1,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    struct = restored.structures[0]
    assert struct.name == "Task"
    assert struct.count == 1
    runtime = cast(Any, struct.runtime)
    assert runtime.always_number is False
    assert restored.resolve(runtime.call) == "DS501"
    assert restored.resolve(runtime.done) == "DS502"


def test_from_nickname_file_named_array_count_one_numbered_names_infer_always_number(tmp_path):
    """count=1 with numbered nicknames (Task1_call) → always_number=True."""
    path = tmp_path / "na_numbered.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Task1_call",
            comment="<Task:named_array(1,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="Task1_done",
            comment="</Task:named_array(1,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    struct = restored.structures[0]
    assert struct.name == "Task"
    assert struct.count == 1
    runtime = cast(Any, struct.runtime)
    assert runtime.always_number is True
    assert restored.resolve(runtime[1].call) == "DS501"
    assert restored.resolve(runtime[1].done) == "DS502"


def test_from_nickname_file_named_array_explicit_always_number_marker(tmp_path):
    """Explicit always_number in marker overrides inference."""
    path = tmp_path / "na_explicit.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Task1_call",
            comment="<Task:named_array(1,2,always_number)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="Task1_done",
            comment="</Task:named_array(1,2,always_number)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    struct = restored.structures[0]
    runtime = cast(Any, struct.runtime)
    assert runtime.always_number is True
    assert restored.resolve(runtime[1].call) == "DS501"


def test_from_nickname_file_structured_identifier_policy_rejects_invalid_tokens(tmp_path):
    path = tmp_path / "invalid_structured_name.csv"
    records = {
        get_addr_key("DS", 701): AddressRecord(
            memory_type="DS",
            address=701,
            nickname="Bad1",
            comment="<Alarm.bad-name:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        )
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="Invalid UDT block tag"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_rejects_duplicate_block_definition_names(tmp_path):
    path = tmp_path / "duplicate_block_defs.csv"
    records = {
        get_addr_key("DS", 801): AddressRecord(
            memory_type="DS",
            address=801,
            nickname="A1",
            comment="<Alarm.id:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 802): AddressRecord(
            memory_type="DS",
            address=802,
            nickname="A2",
            comment="<Alarm.id:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="Duplicate block definition name"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_named_array_bare_marker_infers_count_and_stride(tmp_path):
    """Task:named_array (no args) → count=1, stride inferred from row span."""
    path = tmp_path / "na_bare.csv"
    records = {
        get_addr_key("DS", 601): AddressRecord(
            memory_type="DS",
            address=601,
            nickname="Task_call",
            comment="<Task:named_array>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 602): AddressRecord(
            memory_type="DS",
            address=602,
            nickname="Task_done",
            comment="</Task:named_array>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    struct = restored.structures[0]
    assert struct.name == "Task"
    assert struct.count == 1
    assert struct.stride == 2
    runtime = cast(Any, struct.runtime)
    assert restored.resolve(runtime.call) == "DS601"
    assert restored.resolve(runtime.done) == "DS602"


def test_from_nickname_file_named_array_count_only_infers_stride(tmp_path):
    """Task:named_array(2) → count=2, stride inferred from row span / count."""
    path = tmp_path / "na_count_only.csv"
    records = {
        get_addr_key("DS", 701): AddressRecord(
            memory_type="DS",
            address=701,
            nickname="Task1_call",
            comment="<Task:named_array(2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 702): AddressRecord(
            memory_type="DS",
            address=702,
            nickname="Task1_done",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 703): AddressRecord(
            memory_type="DS",
            address=703,
            nickname="Task2_call",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 704): AddressRecord(
            memory_type="DS",
            address=704,
            nickname="Task2_done",
            comment="</Task:named_array(2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    struct = restored.structures[0]
    assert struct.name == "Task"
    assert struct.count == 2
    assert struct.stride == 2
    runtime = cast(Any, struct.runtime)
    assert restored.resolve(runtime[1].call) == "DS701"
    assert restored.resolve(runtime[2].done) == "DS704"


def test_from_nickname_file_named_array_indivisible_row_count_raises(tmp_path):
    """Task:named_array(3) with 4 rows → not divisible, raises."""
    path = tmp_path / "na_indivisible.csv"
    records = {
        get_addr_key("DS", 801): AddressRecord(
            memory_type="DS",
            address=801,
            nickname="Task1_a",
            comment="<Task:named_array(3)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 802): AddressRecord(
            memory_type="DS",
            address=802,
            nickname="",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 803): AddressRecord(
            memory_type="DS",
            address=803,
            nickname="",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 804): AddressRecord(
            memory_type="DS",
            address=804,
            nickname="",
            comment="</Task:named_array(3)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="not divisible"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_rejects_named_array_then_udt_same_name(tmp_path):
    """Same base name as :named_array then .attr:udt → error."""
    path = tmp_path / "na_then_udt.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Alarm_id",
            comment="<Alarm:named_array(1,1) />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="A1",
            comment="<Alarm.On:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="used as both"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_rejects_udt_then_named_array_same_name(tmp_path):
    """Same base name as .attr:udt then :named_array → error (DS bank first)."""
    path = tmp_path / "udt_then_na.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Alarm_id",
            comment="<Alarm:named_array(1,1) />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 601): AddressRecord(
            memory_type="DS",
            address=601,
            nickname="A1",
            comment="<Alarm.On:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="used as both named_array and udt"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_rejects_duplicate_named_array_markers(tmp_path):
    """Two :named_array markers for the same base name → error."""
    path = tmp_path / "dup_na.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Task_call",
            comment="<Task:named_array(1,1) />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="Task_done",
            comment="<Task:named_array(1,1) />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="Duplicate block definition name|used as both"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_rejects_block_and_named_array_collision(tmp_path):
    """Same base name as :block and :named_array → error."""
    path = tmp_path / "block_na_collision.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Alarm1",
            comment="<Alarm:block />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 601): AddressRecord(
            memory_type="DS",
            address=601,
            nickname="Alarm_id",
            comment="<Alarm:named_array(1,1) />",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="used as both block and named_array"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_named_array_instance2_new_field_raises(tmp_path):
    """Instance 2 introduces a field not in instance 1 → error."""
    path = tmp_path / "na_inst2_new_field.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Alarm1_id",
            comment="<Alarm:named_array(2,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="",
            comment="",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 503): AddressRecord(
            memory_type="DS",
            address=503,
            nickname="Alarm2_id",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 504): AddressRecord(
            memory_type="DS",
            address=504,
            nickname="Alarm2_extra",
            comment="</Alarm:named_array(2,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="instance 2 introduces field.*not present in instance 1"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_named_array_instance1_no_named_fields_raises(tmp_path):
    """Instance 1 has no named fields but instance 2 does → error."""
    path = tmp_path / "na_inst1_empty.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="",
            comment="<Alarm:named_array(2,2)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="",
            comment="",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 503): AddressRecord(
            memory_type="DS",
            address=503,
            nickname="Alarm2_id",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 504): AddressRecord(
            memory_type="DS",
            address=504,
            nickname="",
            comment="</Alarm:named_array(2,2)>",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="instance 1 has no named fields"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_plain_block_auto_starts_at_zero_for_zero_address(tmp_path):
    path = tmp_path / "plain_block_start_zero.csv"
    records = {
        get_addr_key("XD", 0): AddressRecord(
            memory_type="XD",
            address=0,
            nickname="Word0",
            comment="<WordBank:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.HEX,
        ),
        get_addr_key("XD", 2): AddressRecord(
            memory_type="XD",
            address=2,
            nickname="Word2",
            comment="</WordBank:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.HEX,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    block = restored.blocks()[0].logical
    assert block.name == "WordBank"
    assert block.start == 0
    assert block.end == 2
    assert restored.resolve(block, 0) == "XD0"
    assert restored.resolve(block, 2) == "XD1"


def test_from_nickname_file_plain_block_explicit_start_override(tmp_path):
    path = tmp_path / "plain_block_explicit_start.csv"
    records = {
        get_addr_key("XD", 0): AddressRecord(
            memory_type="XD",
            address=0,
            nickname="Word5",
            comment="<WordBank:block(5)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.HEX,
        ),
        get_addr_key("XD", 2): AddressRecord(
            memory_type="XD",
            address=2,
            nickname="Word7",
            comment="</WordBank:block(5)>",
            initial_value="0",
            retentive=False,
            data_type=DataType.HEX,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    block = restored.blocks()[0].logical
    assert block.name == "WordBank"
    assert block.start == 5
    assert block.end == 7
    assert restored.resolve(block, 5) == "XD0"
    assert restored.resolve(block, 7) == "XD1"


def test_from_nickname_file_plain_block_invalid_explicit_start_tag_raises(tmp_path):
    path = tmp_path / "plain_block_bad_start_tag.csv"
    records = {
        get_addr_key("XD", 0): AddressRecord(
            memory_type="XD",
            address=0,
            nickname="Word0",
            comment="<WordBank:block(start=-1) />",
            initial_value="0",
            retentive=False,
            data_type=DataType.HEX,
        )
    }
    pyclickplc.write_csv(path, records)

    with pytest.raises(ValueError, match="Invalid block start tag"):
        TagMap.from_nickname_file(path)


def test_from_nickname_file_invalid_mode_raises_immediately(tmp_path):
    missing_path = tmp_path / "does_not_exist.csv"
    with pytest.raises(ValueError, match="Invalid mode"):
        TagMap.from_nickname_file(missing_path, mode=cast(Any, "nope"))


def test_to_nickname_file_exports_named_array_markers_from_structured_metadata(tmp_path):
    source = tmp_path / "source_named_array.csv"
    records = {
        get_addr_key("DS", 901): AddressRecord(
            memory_type="DS",
            address=901,
            nickname="AlarmPacked1_id",
            comment="<AlarmPacked:named_array(2,3)>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 902): AddressRecord(
            memory_type="DS",
            address=902,
            nickname="AlarmPacked1_val",
            comment="",
            initial_value="10",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 904): AddressRecord(
            memory_type="DS",
            address=904,
            nickname="AlarmPacked2_id",
            comment="",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 905): AddressRecord(
            memory_type="DS",
            address=905,
            nickname="AlarmPacked2_val",
            comment="",
            initial_value="20",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 906): AddressRecord(
            memory_type="DS",
            address=906,
            nickname="",
            comment="</AlarmPacked:named_array(2,3)>",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(source, records)

    restored = TagMap.from_nickname_file(source)
    exported = tmp_path / "exported_named_array.csv"
    restored.to_nickname_file(exported)
    exported_rows = pyclickplc.read_csv(exported)

    assert exported_rows[get_addr_key("DS", 901)].comment == "<AlarmPacked:named_array(2,3)>"
    # End marker is synthesized because DS906 is a gap for this named-array layout.
    assert exported_rows[get_addr_key("DS", 906)].comment == "</AlarmPacked:named_array(2,3)>"
    assert exported_rows[get_addr_key("DS", 906)].nickname == ""


# ---------------------------------------------------------------------------
# owner_of() tests
# ---------------------------------------------------------------------------


def test_owner_of_unmapped_address():
    mapping = TagMap(include_system=False)
    assert mapping.owner_of("DS999") is None


def test_owner_of_named_array(tmp_path):
    path = tmp_path / "na.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Channel1_id",
            comment="<Channel:named_array(2,3)>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="Channel1_val",
            comment="",
            initial_value="10",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 503): AddressRecord(
            memory_type="DS",
            address=503,
            nickname="",
            comment="",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 504): AddressRecord(
            memory_type="DS",
            address=504,
            nickname="Channel2_id",
            comment="",
            initial_value="2",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 505): AddressRecord(
            memory_type="DS",
            address=505,
            nickname="Channel2_val",
            comment="",
            initial_value="20",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 506): AddressRecord(
            memory_type="DS",
            address=506,
            nickname="",
            comment="</Channel:named_array(2,3)>",
            initial_value="",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    owner = restored.owner_of("DS501")
    assert owner is not None
    assert owner.structure_name == "Channel"
    assert owner.instance == 1
    assert owner.field == "id"
    assert owner.structure_type == "named_array"

    owner2 = restored.owner_of("DS505")
    assert owner2 is not None
    assert owner2.instance == 2
    assert owner2.field == "val"


def test_owner_of_udt(tmp_path):
    path = tmp_path / "udt.csv"
    records = {
        get_addr_key("DS", 1001): AddressRecord(
            memory_type="DS",
            address=1001,
            nickname="Motor1_speed",
            comment="<Motor.speed:udt>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 1002): AddressRecord(
            memory_type="DS",
            address=1002,
            nickname="Motor2_speed",
            comment="</Motor.speed:udt>",
            initial_value="0",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Motor1_running",
            comment="<Motor.running:udt>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 102): AddressRecord(
            memory_type="C",
            address=102,
            nickname="Motor2_running",
            comment="</Motor.running:udt>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    owner = restored.owner_of("DS1001")
    assert owner is not None
    assert owner.structure_name == "Motor"
    assert owner.instance == 1
    assert owner.field == "speed"
    assert owner.structure_type == "udt"

    owner2 = restored.owner_of("C102")
    assert owner2 is not None
    assert owner2.structure_name == "Motor"
    assert owner2.instance == 2
    assert owner2.field == "running"
    assert owner2.structure_type == "udt"


def test_owner_of_singleton(tmp_path):
    """Singleton structure (count=1) → instance is None."""
    path = tmp_path / "singleton.csv"
    records = {
        get_addr_key("DS", 301): AddressRecord(
            memory_type="DS",
            address=301,
            nickname="Config1_timeout",
            comment="<Config.timeout:udt />",
            initial_value="100",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 201): AddressRecord(
            memory_type="C",
            address=201,
            nickname="Config1_enabled",
            comment="<Config.enabled:udt />",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    owner = restored.owner_of("DS301")
    assert owner is not None
    assert owner.structure_name == "Config"
    assert owner.instance is None
    assert owner.field == "timeout"
    assert owner.structure_type == "udt"


def test_owner_of_plain_block(tmp_path):
    path = tmp_path / "plain.csv"
    records = {
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1",
            comment="<Alarm:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 102): AddressRecord(
            memory_type="C",
            address=102,
            nickname="Alarm2",
            comment="</Alarm:block>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    owner = restored.owner_of("C101")
    assert owner is not None
    assert owner.structure_name == "Alarm"
    assert owner.instance == 1
    assert owner.field is None
    assert owner.structure_type == "block"


def test_bare_plain_block_marker_is_group_only(tmp_path):
    path = tmp_path / "bare_plain_group.csv"
    records = {
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="Alarm1",
            comment="<Alarm>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 102): AddressRecord(
            memory_type="C",
            address=102,
            nickname="Alarm2",
            comment="</Alarm>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert restored.blocks() == ()
    assert restored.owner_of("C101") is None
    assert restored.resolve("Alarm1") == "C101"
    assert restored.resolve("Alarm2") == "C102"


def test_bare_dotted_marker_is_group_only(tmp_path):
    path = tmp_path / "bare_dotted_group.csv"
    records = {
        get_addr_key("DS", 301): AddressRecord(
            memory_type="DS",
            address=301,
            nickname="Config1_timeout",
            comment="<Config.timeout />",
            initial_value="100",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("C", 201): AddressRecord(
            memory_type="C",
            address=201,
            nickname="Config1_enabled",
            comment="<Config.enabled />",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert restored.structures == ()
    assert restored.owner_of("DS301") is None
    assert restored.resolve("Config1_timeout") == "DS301"
    assert restored.resolve("Config1_enabled") == "C201"


def test_group_block_import_stays_standalone_tags(tmp_path):
    path = tmp_path / "group.csv"
    records = {
        get_addr_key("C", 101): AddressRecord(
            memory_type="C",
            address=101,
            nickname="AlarmA",
            comment="<Alarm:group>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("C", 102): AddressRecord(
            memory_type="C",
            address=102,
            nickname="AlarmB",
            comment="</Alarm:group>",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    assert restored.block_entry_by_name("Alarm") is None
    assert restored.owner_of("C101") is None
    assert restored.resolve("AlarmA") == "C101"
    assert restored.resolve("AlarmB") == "C102"


def test_owner_of_structure_wins_over_block(tmp_path):
    """When a named_array overlays a block, named_array wins."""
    path = tmp_path / "overlap.csv"
    records = {
        get_addr_key("DS", 501): AddressRecord(
            memory_type="DS",
            address=501,
            nickname="Packed1_id",
            comment="<Packed:named_array(1,2)>",
            initial_value="1",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("DS", 502): AddressRecord(
            memory_type="DS",
            address=502,
            nickname="Packed1_val",
            comment="</Packed:named_array(1,2)>",
            initial_value="10",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    owner = restored.owner_of("DS501")
    assert owner is not None
    assert owner.structure_type == "named_array"
    assert owner.structure_name == "Packed"


def test_from_nickname_file_preserves_custom_sc_sd_nicknames_for_non_reserved_addresses(tmp_path):
    path = tmp_path / "custom_system_banks.csv"
    records = {
        get_addr_key("SC", 20): AddressRecord(
            memory_type="SC",
            address=20,
            nickname="ModeReady",
            comment="",
            initial_value="0",
            retentive=False,
            data_type=DataType.BIT,
        ),
        get_addr_key("SD", 91): AddressRecord(
            memory_type="SD",
            address=91,
            nickname="RecipeShadow",
            comment="",
            initial_value="123",
            retentive=False,
            data_type=DataType.INT,
        ),
        get_addr_key("SD", 20): AddressRecord(
            memory_type="SD",
            address=20,
            nickname="DontOverrideRtcYear2",
            comment="",
            initial_value="2026",
            retentive=False,
            data_type=DataType.INT,
        ),
    }
    pyclickplc.write_csv(path, records)

    restored = TagMap.from_nickname_file(path)
    custom_slots = {
        slot.logical_name: slot for slot in restored.mapped_slots() if slot.source == "user"
    }
    system_slots = {
        slot.logical_name: slot for slot in restored.mapped_slots() if slot.source == "system"
    }

    assert restored.resolve("ModeReady") == "SC20"
    assert restored.resolve("RecipeShadow") == "SD91"
    assert custom_slots["ModeReady"].hardware_address == "SC20"
    assert custom_slots["RecipeShadow"].hardware_address == "SD91"

    with pytest.raises(KeyError):
        restored.resolve("DontOverrideRtcYear2")

    assert restored.resolve("rtc.year2") == "SD20"
    assert system_slots["rtc.year2"].hardware_address == "SD20"


# ── tags_from_plc_data ──────────────────────────────────────────────


def test_tags_from_plc_data_standalone_tags():
    valve = Bool("Valve")
    speed = Tag("Speed", TagType.INT)
    mapping = TagMap({valve: c[1], speed: ds[1]})

    data = {"C1": True, "DS1": 42, "DS999": 100}
    result = mapping.tags_from_plc_data(data)

    assert result == {"Valve": True, "Speed": 42}


def test_tags_from_plc_data_block_slots():
    alarms = Block("Alarm", TagType.BOOL, 1, 3)
    mapping = TagMap({alarms: c.select(101, 103)})

    data = {"C101": True, "C102": False, "C103": True}
    result = mapping.tags_from_plc_data(data)

    assert result == {"Alarm1": True, "Alarm2": False, "Alarm3": True}


def test_tags_from_plc_data_skips_unmapped():
    valve = Bool("Valve")
    mapping = TagMap({valve: c[1]})

    data = {"C1": True, "C999": True, "DS50": 7, "X001": False}
    result = mapping.tags_from_plc_data(data)

    assert result == {"Valve": True}


def test_tags_from_plc_data_empty_data():
    valve = Bool("Valve")
    mapping = TagMap({valve: c[1]})

    assert mapping.tags_from_plc_data({}) == {}


def test_tags_from_plc_data_skips_xd_yd():
    """XD/YD are mirrored word views; tags_from_plc_data should skip them."""
    valve = Bool("Valve")
    mapping = TagMap({valve: x[1]})

    data = {"X001": True, "XD0u": 1}
    result = mapping.tags_from_plc_data(data)

    assert result == {"Valve": True}


def test_tags_from_plc_data_includes_system_tags():
    """System tags (SC/SD) should be translated when present."""
    mapping = TagMap({}, include_system=True)

    data = {"SC11": True}
    result = mapping.tags_from_plc_data(data)

    assert "_PLC_Mode" in result or len(result) == 1
