"""asset-id.txt names the device, and that name survives a PC swap.

Every other identity the collector has for a subordinate device is derived from
the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up
`<PC number>-<SUFFIX>`. Both survive a re-image and neither survives a swap - a
new hostname is a new PC asset with no prior link and a predicted number that
has never existed, so the same physical device gets a second record while the
first keeps its history under a dead PC's name.

That is the failure that left 43 measuring tools shadowed by minted twins. These
tests pin the fix for BOTH device families, because the part-marker path was
modelled on the metrology path as it stood before it was fixed.
"""

import pytest

from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship

KEY = 'deviceid-key'


@pytest.fixture
def collector_key(app):
    old = app.config.get('COLLECTOR_API_KEY')
    app.config['COLLECTOR_API_KEY'] = KEY
    yield KEY
    app.config['COLLECTOR_API_KEY'] = old


@pytest.fixture
def rig(db):
    for name in ('computer', 'machine', 'measuring_tool'):
        if not AssetType.query.filter_by(assettype=name).first():
            db.session.add(AssetType(assettype=name))
    for name in ('controls', 'partof'):
        if not RelationshipType.query.filter_by(relationshiptype=name).first():
            db.session.add(RelationshipType(relationshiptype=name))
    db.session.commit()


def _report(client, key, hostname, pctype, **extra):
    payload = {'hostname': hostname, 'pctype': pctype}
    payload.update(extra)
    return client.post('/api/collector/computers', json=payload,
                       headers={'X-API-Key': key})


def _asset(db, assetnumber, assettype='machine'):
    """A bare asset: an operation, or something that is NOT a device."""
    at = AssetType.query.filter_by(assettype=assettype).first()
    asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
    db.session.add(asset)
    db.session.commit()
    return asset


def _marker(db, assetnumber):
    """A Part Marker the collector did NOT create - the real unit on the floor.

    The extension row and machine type are what make it a marker; a bare asset
    of the right number is deliberately refused, which the wrong-type test pins.
    """
    from plugins.machines.models import Machine, MachineType
    at = AssetType.query.filter_by(assettype='machine').first()
    mtype = MachineType.query.filter_by(machinetype='Part Marker').first()
    if mtype is None:
        mtype = MachineType(machinetype='Part Marker')
        db.session.add(mtype)
        db.session.flush()
    asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
    db.session.add(asset)
    db.session.flush()
    db.session.add(Machine(assetid=asset.assetid,
                           machinetypeid=mtype.machinetypeid))
    db.session.commit()
    return asset


def _tool(db, assetnumber):
    """A measuring tool the collector did NOT create."""
    from plugins.measuringtools.models import MeasuringTool
    at = AssetType.query.filter_by(assettype='measuring_tool').first()
    asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
    db.session.add(asset)
    db.session.flush()
    db.session.add(MeasuringTool(assetid=asset.assetid))
    db.session.commit()
    return asset


def _controlled(pcname, label):
    """Asset numbers this PC controls under a collector label."""
    pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
    if pc is None:
        return []
    rels = AssetRelationship.query.filter_by(
        sourceassetid=pc.assetid, label=label, isactive=True).all()
    return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber
                  for r in rels)


# --------------------------------------------------------------- part markers

def _retire(db, hostname):
    """Move a PC off In Use, which is the one-step way to yield its device."""
    from shopdb.core.models import AssetStatus
    retired = AssetStatus.query.filter_by(status='Retired').first()
    if retired is None:
        retired = AssetStatus(status='Retired')
        db.session.add(retired)
        db.session.flush()
    pc = Asset.query.filter(Asset.assetnumber.ilike(hostname)).first()
    pc.statusid = retired.statusid
    db.session.commit()


def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key):
    """THE case this exists for. Same physical marker, two different PCs.

    Neither PC mints a twin. Who HOLDS the marker is settled separately, by the
    two tests below - this one pins only that the physical unit stays one row.
    """
    _asset(db, '0613')
    marker = _marker(db, 'PM-0613-A')

    first = _report(client, collector_key, 'FMARK100',
                    pctype='gea-shopfloor-partmarker',
                    machinenumber='0613', deviceid='PM-0613-A')
    assert first.status_code in (200, 201), first.get_data(as_text=True)[:300]

    # The bay's PC is replaced. New hostname, same marker named in asset-id.txt.
    second = _report(client, collector_key, 'FMARK200',
                     pctype='gea-shopfloor-partmarker',
                     machinenumber='0613', deviceid='PM-0613-A')
    assert second.status_code in (200, 201), second.get_data(as_text=True)[:300]

    assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
    assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
    assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1
    assert marker.assetid == Asset.query.filter_by(
        assetnumber='PM-0613-A').first().assetid


def test_a_live_incumbent_keeps_the_marker_and_the_challenger_is_dormant(
        client, db, rig, collector_key):
    """Two PCs naming one device must not both hold it actively.

    Before this, neither device path looked at who else held the target, so a
    replaced PC kept its link forever and a copied asset-id.txt claimed the same
    marker from every bay, silently.
    """
    _asset(db, '0613')
    _marker(db, 'PM-0613-A')
    _report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
            machinenumber='0613', deviceid='PM-0613-A')
    resp = _report(client, collector_key, 'FMARK200',
                   pctype='gea-shopfloor-partmarker',
                   machinenumber='0613', deviceid='PM-0613-A')

    assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
    assert _controlled('FMARK200', 'collector:partmarker') == []
    warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
    assert 'FMARK100' in warnings and 'PM-0613-A' in warnings


def test_handover_completes_once_the_incumbent_yields(client, db, rig,
                                                      collector_key):
    """The swap case as it actually happens: the old PC is retired or goes quiet."""
    _asset(db, '0613')
    _marker(db, 'PM-0613-A')
    _report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
            machinenumber='0613', deviceid='PM-0613-A')
    _retire(db, 'FMARK100')

    _report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
            machinenumber='0613', deviceid='PM-0613-A')

    assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
    # Archived, never deleted: "which PC drove this in June" stays answerable.
    assert _controlled('FMARK100', 'collector:partmarker') == []
    assert AssetRelationship.query.filter_by(label='collector:partmarker').count() >= 2


def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig,
                                                         collector_key):
    """The unfixed behaviour, pinned so the file's value stays visible."""
    _asset(db, '0614')
    assert _report(client, collector_key, 'FMARK300',
                   pctype='gea-shopfloor-partmarker',
                   machinenumber='0614').status_code in (200, 201)
    assert _report(client, collector_key, 'FMARK400',
                   pctype='gea-shopfloor-partmarker',
                   machinenumber='0614').status_code in (200, 201)
    assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first()
    assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first()


def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
                                                   collector_key):
    _asset(db, '0616')
    resp = _report(client, collector_key, 'FMARK500',
                   pctype='gea-shopfloor-partmarker',
                   machinenumber='0616', deviceid='PM-TYPO')
    assert resp.status_code in (200, 201)
    assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None
    warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
    assert 'PM-TYPO' in warnings
    # LINKS NOTHING, which is what the name claims. This used to warn and then
    # mint FMARK500-PARTMARKER anyway - the twin the whole path exists to stop -
    # and the test passed because it only checked that PM-TYPO was not created.
    assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None
    assert _controlled('FMARK500', 'collector:partmarker') == []
    # AND it must not fall through to claiming the OPERATION directly. A marker
    # PC reports the operation as its machine number, so "link nothing" that
    # let the ordinary machine link run traded a phantom marker for a contested
    # operation - several marker PCs share one, and it holds a single link.
    assert _controlled('FMARK500', 'collector:machine') == []


def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
    """A machine number pasted into asset-id.txt must not become a marker."""
    _asset(db, '0617')
    _asset(db, 'PLAIN-MACHINE')
    resp = _report(client, collector_key, 'FMARK600',
                   pctype='gea-shopfloor-partmarker',
                   machinenumber='0617', deviceid='PLAIN-MACHINE')
    assert resp.status_code in (200, 201)
    assert _controlled('FMARK600', 'collector:partmarker') == []
    assert _controlled('FMARK600', 'collector:machine') == []
    assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
    warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
    assert 'PLAIN-MACHINE' in warnings


def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
    _asset(db, '0618')
    _marker(db, 'PM-0618-A')
    codes = [_report(client, collector_key, 'FMARK700',
                     pctype='gea-shopfloor-partmarker',
                     machinenumber='0618', deviceid='PM-0618-A').status_code
             for _ in range(3)]
    assert codes == [codes[0]] * 3, codes
    assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']


# ------------------------------------------------------------ measuring tools

def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
    """One file, no device type in it: the pc-type decides which sync uses it."""
    _tool(db, 'MT-9001')
    resp = _report(client, collector_key, 'KEYENCE100',
                   pctype='gea-shopfloor-keyence', deviceid='MT-9001')
    assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
    assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
    assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None


def test_the_0120_field_name_still_works(client, db, rig, collector_key):
    """measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
    _tool(db, 'MT-9002')
    resp = _report(client, collector_key, 'KEYENCE200',
                   pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
    assert resp.status_code in (200, 201)
    assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']


def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
    _tool(db, 'MT-9003')
    _tool(db, 'MT-9004')
    resp = _report(client, collector_key, 'KEYENCE300',
                   pctype='gea-shopfloor-keyence',
                   deviceid='MT-9003', measuringtoolid='MT-9004')
    assert resp.status_code in (200, 201)
    assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']


def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
        client, db, rig, collector_key):
    """Deleting asset-id.txt must not hand a device to the challenger.

    The dormant row this feature creates is found as `reuse` on the next cycle.
    Reactivating it unconditionally meant a second PC took a live device by
    losing a file - no warning, two active holders.
    """
    _asset(db, '0613')
    _marker(db, 'PM-0613-A')
    _report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
            machinenumber='0613', deviceid='PM-0613-A')
    _report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
            machinenumber='0613', deviceid='PM-0613-A')
    assert _controlled('FMARK200', 'collector:partmarker') == []

    # Next cycle: the file is gone, so no deviceid is sent at all.
    _report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
            machinenumber='0613')

    assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
    assert _controlled('FMARK200', 'collector:partmarker') == []


def test_an_incumbent_under_another_collector_label_is_still_seen(
        client, db, rig, collector_key):
    """On a CMM the instrument IS the bay, so the incumbent's link is the
    machine sync's row under collector:machine. Counting only our own label
    left two live holders of one instrument, each invisible to the other.
    """
    _tool(db, 'CMM4')
    # A LIVE incumbent: it reported through the collector, so it has a computer
    # row and a recent check-in. A bare asset would correctly be treated as
    # yielded, since something that cannot report cannot be holding anything.
    _report(client, collector_key, 'WCMM100',
            pctype='gea-shopfloor-keyence', deviceid='CMM4')
    # Relabel its link to the machine sync's origin, which is the shape a CMM
    # really has: there the instrument IS the reported bay.
    pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first()
    held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first()
    held.label = 'collector:machine'
    db.session.commit()

    resp = _report(client, collector_key, 'WCMM200',
                   pctype='gea-shopfloor-keyence', deviceid='CMM4')

    assert _controlled('WCMM200', 'collector:measuringtool') == []
    warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
    assert 'CMM4' in warnings
