"""Tests for the generic plugin collector contract (ADR-006).

Covers the auto-dispatched /api/collector/<plugin> endpoint, per-plugin API
key auth, idempotent upsert by identity field, and the JWT-protected
/api/collector/_schemas listing.
"""

import pytest

KEY = 'testcollectorkey'


@pytest.fixture
def collector_key(app):
    """Set the shared collector API key for the test."""
    old = app.config.get('COLLECTOR_API_KEY')
    app.config['COLLECTOR_API_KEY'] = KEY
    yield KEY
    app.config['COLLECTOR_API_KEY'] = old


@pytest.fixture
def computer_assettype(db):
    """Seed the computer asset type needed for create-on-ingest."""
    from shopdb.core.models import AssetType

    at = AssetType(assettype='computer', pluginname='computers',
                   tablename='computers', description='PCs')
    db.session.add(at)
    db.session.commit()
    return at


def test_schemas_requires_jwt(client, db):
    """The schema listing rejects unauthenticated callers."""
    response = client.get('/api/collector/_schemas')
    assert response.status_code == 401


def test_schemas_lists_computers(client, db, auth_headers):
    """Computers plugin exposes a collector schema keyed by hostname."""
    response = client.get('/api/collector/_schemas', headers=auth_headers)
    assert response.status_code == 200
    schemas = response.get_json()['data']['schemas']
    assert 'computers' in schemas
    assert schemas['computers']['identityfield'] == 'hostname'


def test_missing_key_rejected(client, db, collector_key, computer_assettype):
    """No API key -> 401."""
    response = client.post('/api/collector/computers',
                           json={'hostname': 'WJPC001'})
    assert response.status_code == 401


def test_unknown_plugin_404(client, db, collector_key):
    """A plugin with no collector schema returns 404."""
    response = client.post('/api/collector/nosuchplugin',
                           json={'hostname': 'x'},
                           headers={'X-API-Key': KEY})
    assert response.status_code == 404


def test_missing_identity_rejected(client, db, collector_key, computer_assettype):
    """Missing the identity field is a validation error."""
    response = client.post('/api/collector/computers',
                           json={'currentuser': 'someone'},
                           headers={'X-API-Key': KEY})
    assert response.status_code == 400


def test_create_then_idempotent_update(client, db, collector_key,
                                       computer_assettype):
    """First post creates; second post with same hostname updates (no dup)."""
    from plugins.computers.models import Computer

    payload = {'hostname': 'WJPC100', 'currentuser': 'alice',
               'serialnumber': 'SN-100'}

    first = client.post('/api/collector/computers', json=payload,
                        headers={'X-API-Key': KEY})
    assert first.status_code == 200, first.get_json()
    data = first.get_json()['data']
    assert data['action'] == 'created'
    assert data['assetid'] is not None
    assert data['identityvalue'] == 'WJPC100'

    payload['currentuser'] = 'bob'
    second = client.post('/api/collector/computers', json=payload,
                         headers={'X-API-Key': KEY})
    assert second.status_code == 200
    assert second.get_json()['data']['action'] == 'updated'

    with client.application.app_context():
        comps = Computer.query.filter(Computer.hostname.ilike('WJPC100')).all()
        assert len(comps) == 1
        assert comps[0].loggedinuser == 'bob'


def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype):
    """COLLECTOR_API_KEY_<PLUGIN> takes precedence over the shared key."""
    app.config['COLLECTOR_API_KEY'] = 'sharedkey'
    app.config['COLLECTOR_API_KEY_COMPUTERS'] = 'computerskey'
    try:
        # Shared key now rejected for this plugin.
        rejected = client.post('/api/collector/computers',
                               json={'hostname': 'WJPC200'},
                               headers={'X-API-Key': 'sharedkey'})
        assert rejected.status_code == 401

        accepted = client.post('/api/collector/computers',
                               json={'hostname': 'WJPC200'},
                               headers={'X-API-Key': 'computerskey'})
        assert accepted.status_code == 200
    finally:
        app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None)
        app.config['COLLECTOR_API_KEY'] = None


def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key,
                                                     computer_assettype):
    """A GE-Enforce-shaped payload maps machinenumber/pctype/vendor/model/os."""
    from shopdb.core.models import OperatingSystem
    from plugins.computers.models import Computer, ComputerType

    # Seed the controlled-vocab rows the collector looks up.
    db.session.add(ComputerType(computertype='Shopfloor PC'))
    db.session.add(OperatingSystem(osname='Windows 11'))
    db.session.commit()

    payload = {
        'hostname': 'WJSF1234',
        'machinenumber': '0615',
        'pctype': 'gea-shopfloor-collections',
        'serialnumber': 'SN-ENROLL',
        'vendorname': 'Dell',
        'modelnumber': 'OptiPlex 7090',
        'osname': 'Windows 11',
    }
    resp = client.post('/api/collector/computers', json=payload,
                       headers={'X-API-Key': KEY})
    assert resp.status_code == 200, resp.get_json()
    assert resp.get_json()['data']['action'] == 'created'

    with client.application.app_context():
        comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first()
        assert comp is not None
        # The PC keeps its OWN assetnumber (hostname). machinenumber names the
        # MACHINE, and machines are assets too (ADR-001), so assigning it here
        # hit the unique index on assets.assetnumber and returned 500 to the bay
        # on every retry. The machine number now builds a controls link instead,
        # which is what the reported number was actually for.
        assert comp.asset.assetnumber == 'WJSF1234'
        assert comp.asset.serialnumber == 'SN-ENROLL'
        assert comp.computertype.computertype == 'Shopfloor PC'  # pctype mapped
        assert comp.vendor.vendor == 'Dell'              # created
        assert comp.model.modelnumber == 'OptiPlex 7090'  # created
        assert comp.operatingsystem.osname == 'Windows 11'


def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db,
                                                               collector_key,
                                                               computer_assettype):
    """The 9999 imaging placeholder is ignored; assetnumber falls back to host."""
    resp = client.post('/api/collector/computers',
                       json={'hostname': 'WJSF9999', 'machinenumber': '9999'},
                       headers={'X-API-Key': KEY})
    assert resp.status_code == 200
    from plugins.computers.models import Computer
    with client.application.app_context():
        comp = Computer.query.filter(Computer.hostname.ilike('WJSF9999')).first()
        assert comp.asset.assetnumber == 'WJSF9999'


def test_internal_error_message_is_generic(client, app, db, collector_key):
    """An unexpected upsert failure returns a generic 500, not the exception text."""
    pm = app.extensions['plugin_manager']
    plugin = pm.get_all_plugins()['computers']
    original = plugin.apply_collector_payload

    def boom(payload):
        raise RuntimeError('secret internal dsn leaked here')

    plugin.apply_collector_payload = boom
    try:
        resp = client.post('/api/collector/computers',
                           json={'hostname': 'WJPC500'},
                           headers={'X-API-Key': KEY})
    finally:
        plugin.apply_collector_payload = original

    assert resp.status_code == 500
    message = resp.get_json()['data']['error']['message']
    assert message == 'Internal error processing collector payload'
    assert 'secret internal dsn' not in message


def test_generic_querystring_api_key_rejected(client, db, collector_key,
                                              computer_assettype):
    """The dropped ?api_key= querystring fallback no longer authenticates."""
    resp = client.post('/api/collector/computers?api_key=' + KEY,
                       json={'hostname': 'WJPCQS'})
    assert resp.status_code == 401


def test_legacy_querystring_api_key_rejected(client, db, collector_key):
    """Header-only auth on the legacy endpoints too: querystring key is rejected."""
    resp = client.get('/api/collector/status?api_key=' + KEY)
    assert resp.status_code == 401


def test_legacy_header_api_key_accepted(client, db, collector_key):
    """The X-API-Key header still authenticates the legacy endpoints."""
    resp = client.get('/api/collector/status', headers={'X-API-Key': KEY})
    assert resp.status_code == 200


# --- Managed collector service tokens ------------------------------------

def _mint_collector_token(client, auth_headers, scopes):
    """Mint a managed token with the given scopes as the logged-in admin."""
    resp = client.post('/api/apitokens',
                       json={'name': 'collector svc', 'scopes': scopes},
                       headers=auth_headers)
    assert resp.status_code == 201, resp.get_json()
    return resp.get_json()['data']['secret']


def test_collector_token_via_x_api_key(client, db, auth_headers,
                                       computer_assettype):
    """A collector-scoped managed token authorizes ingest via X-API-Key, with
    no env key set."""
    secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
    resp = client.post('/api/collector/computers',
                       json={'hostname': 'WJTOK01'},
                       headers={'X-API-Key': secret})
    assert resp.status_code == 200, resp.get_json()
    assert resp.get_json()['data']['action'] == 'created'


def test_collector_token_via_bearer(client, db, auth_headers,
                                    computer_assettype):
    """The same token also works on the standard Bearer PAT transport."""
    secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
    resp = client.post('/api/collector/computers',
                       json={'hostname': 'WJTOK02'},
                       headers={'Authorization': f'Bearer {secret}'})
    assert resp.status_code == 200, resp.get_json()
    assert resp.get_json()['data']['action'] == 'created'


def test_collector_token_stamps_lastusedat(client, db, auth_headers,
                                           computer_assettype):
    """Using a collector token stamps lastusedat."""
    from shopdb.core.models import ApiToken

    secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    assert token.lastusedat is None

    client.post('/api/collector/computers', json={'hostname': 'WJTOK03'},
                headers={'X-API-Key': secret})
    db.session.expire_all()
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    assert token.lastusedat is not None


def test_unscoped_token_rejected_by_collector(client, db, auth_headers,
                                              collector_key, computer_assettype):
    """An unscoped PAT is NOT a collector token: it lacks the collector.ingest
    scope, so the collector API rejects it (env-key semantics unchanged)."""
    secret = client.post('/api/apitokens', json={'name': 'full'},
                         headers=auth_headers).get_json()['data']['secret']
    resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK04'},
                       headers={'X-API-Key': secret})
    assert resp.status_code == 401


def test_wrong_scoped_token_rejected_by_collector(client, db, auth_headers,
                                                  collector_key,
                                                  computer_assettype):
    """A PAT scoped to some OTHER permission is not a collector token."""
    secret = _mint_collector_token(client, auth_headers, ['applications.create'])
    resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK05'},
                       headers={'X-API-Key': secret})
    assert resp.status_code == 401


def test_revoked_collector_token_rejected(client, db, auth_headers,
                                          collector_key, computer_assettype):
    """A revoked collector token is rejected on both transports."""
    from shopdb.core.models import ApiToken

    secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
    tokenid = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first().tokenid
    assert client.delete(f'/api/apitokens/{tokenid}',
                        headers=auth_headers).status_code == 200

    viakey = client.post('/api/collector/computers', json={'hostname': 'WJTOK06'},
                        headers={'X-API-Key': secret})
    assert viakey.status_code == 401
    viabearer = client.post('/api/collector/computers',
                           json={'hostname': 'WJTOK06'},
                           headers={'Authorization': f'Bearer {secret}'})
    assert viabearer.status_code == 401


def test_collector_token_contained_to_collector_api(client, db, admin_user,
                                                    auth_headers):
    """Containment: a collector-scoped token is denied on a permission-gated
    write (applications.create) AND a role-gated write (vendors, require_role)."""
    from shopdb.core.models import Application, Vendor

    secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])

    perm_gated = client.post('/api/applications', json={'appname': 'Nope'},
                            headers={'Authorization': f'Bearer {secret}'})
    assert perm_gated.status_code == 403
    assert Application.query.filter_by(appname='Nope').first() is None

    role_gated = client.post('/api/vendors', json={'vendor': 'Nope'},
                            headers={'Authorization': f'Bearer {secret}'})
    assert role_gated.status_code == 403
    assert Vendor.query.filter_by(vendor='Nope').first() is None


def test_env_key_still_works_alongside_tokens(client, db, collector_key,
                                              computer_assettype):
    """Regression: the env key path is unchanged when tokens are in play."""
    resp = client.post('/api/collector/computers', json={'hostname': 'WJENV01'},
                       headers={'X-API-Key': KEY})
    assert resp.status_code == 200
    assert resp.get_json()['data']['action'] == 'created'


# -- metrology measuring-tool auto-link ------------------------------------
# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector
# creates the attached measuring instrument as a MeasuringTool asset and links
# the PC to it with a directional 'controls' relationship.

@pytest.fixture
def measuringtool_env(db):
    """Seed the measuring_tool asset type + 'controls' relationship type the
    metrology collector path links through."""
    from shopdb.core.models import AssetType, RelationshipType
    db.session.add(AssetType(assettype='measuring_tool',
                             pluginname='measuringtools',
                             tablename='measuringtools',
                             description='Metrology instruments'))
    db.session.add(RelationshipType(relationshiptype='controls',
                                    description='Operational authority',
                                    isdirectional=True))
    db.session.commit()


def test_metrology_pctype_creates_and_links_measuringtool(
        client, db, collector_key, computer_assettype, measuringtool_env):
    """A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type
    is auto-created (it is not in the measuringtools starter seed)."""
    from plugins.computers.models import Computer
    from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
    from shopdb.core.models import AssetRelationship

    payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'}
    resp = client.post('/api/collector/computers', json=payload,
                       headers={'X-API-Key': KEY})
    assert resp.status_code == 200, resp.get_json()
    data = resp.get_json()['data']
    assert data['measuringtoollinkcount'] == 1
    link = data['measuringtoollinks'][0]
    assert link['relationshiptype'] == 'controls'
    assert link['measuringtooltype'] == 'CMM'

    with client.application.app_context():
        tools = MeasuringTool.query.all()
        assert len(tools) == 1
        assert MeasuringToolType.query.filter_by(name='CMM').first() is not None
        pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first()
        rels = AssetRelationship.query.filter_by(
            sourceassetid=pc.assetid, label='collector:measuringtool').all()
        assert len(rels) == 1
        assert rels[0].targetassetid == tools[0].assetid
        assert rels[0].isactive


def test_metrology_link_idempotent(client, db, collector_key,
                                   computer_assettype, measuringtool_env):
    """Re-pushing the same metrology PC does not duplicate the tool asset."""
    from plugins.measuringtools.models import MeasuringTool

    payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'}
    for _ in range(2):
        resp = client.post('/api/collector/computers', json=payload,
                           headers={'X-API-Key': KEY})
        assert resp.status_code == 200
    with client.application.app_context():
        assert MeasuringTool.query.count() == 1


def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key,
                                           computer_assettype, measuringtool_env):
    """A plain shopfloor PC gets no measuring-tool asset."""
    from plugins.measuringtools.models import MeasuringTool

    payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'}
    resp = client.post('/api/collector/computers', json=payload,
                       headers={'X-API-Key': KEY})
    assert resp.status_code == 200
    assert resp.get_json()['data']['measuringtoollinkcount'] == 0
    with client.application.app_context():
        assert MeasuringTool.query.count() == 0


def test_repurposed_pc_archives_tool_link(client, db, collector_key,
                                          computer_assettype, measuringtool_env):
    """A metrology PC re-imaged to a non-metrology type has its collector tool
    link archived (tool asset kept, relationship deactivated)."""
    from plugins.measuringtools.models import MeasuringTool
    from shopdb.core.models import AssetRelationship

    host = {'hostname': 'WJCMM03'}
    client.post('/api/collector/computers',
                json={**host, 'pctype': 'gea-shopfloor-cmm'},
                headers={'X-API-Key': KEY})
    resp = client.post('/api/collector/computers',
                       json={**host, 'pctype': 'gea-shopfloor-collections'},
                       headers={'X-API-Key': KEY})
    assert resp.status_code == 200
    assert resp.get_json()['data']['measuringtoollinkcount'] == 0
    with client.application.app_context():
        # asset preserved (may carry calibration history), link deactivated
        assert MeasuringTool.query.count() == 1
        rels = AssetRelationship.query.filter_by(
            label='collector:measuringtool').all()
        assert len(rels) == 1
        assert rels[0].isactive is False


# =============================================================================
# Remote-access protocol sync (RealVNC and friends)
# =============================================================================

@pytest.fixture
def access_protocols(db):
    """Seed the admin-managed protocol catalog the collector matches against."""
    from plugins.computers.models import AccessProtocol

    db.session.add_all([
        AccessProtocol(name='VNC', scheme='vnc', defaultport=5900,
                       linktemplate='vnc://{host}:{port}'),
        AccessProtocol(name='WinRM', scheme='https', defaultport=5986,
                       linktemplate='https://{host}:{port}'),
    ])
    db.session.commit()


def _post(client, collector_key, payload):
    return client.post('/api/collector/computers', json=payload,
                       headers={'X-API-Key': collector_key})


def _active_protocols(hostname):
    from plugins.computers.models import Computer, ComputerAccess, AccessProtocol
    comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
    rows = ComputerAccess.query.filter_by(computerid=comp.computerid,
                                          isactive=True).all()
    ids = [r.protocolid for r in rows]
    return sorted(p.name for p in AccessProtocol.query.filter(
        AccessProtocol.protocolid.in_(ids)).all()) if ids else []


def test_reported_vnc_is_recorded(client, db, collector_key,
                                  computer_assettype, access_protocols):
    response = _post(client, collector_key,
                     {'hostname': 'BAY001', 'accessprotocols': ['VNC']})
    assert response.status_code == 200, response.get_json()
    assert _active_protocols('BAY001') == ['VNC']


def test_protocol_match_is_case_insensitive(client, db, collector_key,
                                            computer_assettype, access_protocols):
    _post(client, collector_key,
          {'hostname': 'BAY002', 'accessprotocols': ['vnc']})
    assert _active_protocols('BAY002') == ['VNC']


def test_protocol_no_longer_reported_is_deactivated(client, db, collector_key,
                                                    computer_assettype,
                                                    access_protocols):
    """RealVNC removed from a bay must stop showing as available."""
    _post(client, collector_key,
          {'hostname': 'BAY003', 'accessprotocols': ['VNC', 'WinRM']})
    assert _active_protocols('BAY003') == ['VNC', 'WinRM']
    _post(client, collector_key,
          {'hostname': 'BAY003', 'accessprotocols': ['WinRM']})
    assert _active_protocols('BAY003') == ['WinRM']


def test_omitting_the_key_leaves_existing_rows_alone(client, db, collector_key,
                                                     computer_assettype,
                                                     access_protocols):
    """Most rows came from the legacy isvnc/iswinrm migration. A collector that
    does not report protocols yet must not wipe them."""
    _post(client, collector_key,
          {'hostname': 'BAY004', 'accessprotocols': ['VNC']})
    _post(client, collector_key, {'hostname': 'BAY004', 'loggedinuser': 'someone'})
    assert _active_protocols('BAY004') == ['VNC']


def test_empty_list_deactivates_everything(client, db, collector_key,
                                           computer_assettype, access_protocols):
    """An explicit empty list means 'this PC exposes nothing' - distinct from
    omitting the key."""
    _post(client, collector_key,
          {'hostname': 'BAY005', 'accessprotocols': ['VNC']})
    _post(client, collector_key, {'hostname': 'BAY005', 'accessprotocols': []})
    assert _active_protocols('BAY005') == []


def test_unknown_protocol_warns_and_does_not_create_one(client, db, collector_key,
                                                        computer_assettype,
                                                        access_protocols):
    """The catalog is admin-managed: a typo on one bay must not invent a
    protocol for the whole site."""
    from plugins.computers.models import AccessProtocol

    response = _post(client, collector_key,
                     {'hostname': 'BAY006', 'accessprotocols': ['TeamViewer']})
    body = response.get_json()['data']
    assert any('TeamViewer' in w for w in body['warnings'])
    assert AccessProtocol.query.filter(
        AccessProtocol.name.ilike('TeamViewer')).first() is None


# =============================================================================
# PC -> machine link, and the PC-swap case
# =============================================================================

@pytest.fixture
def machine_3015(db):
    """A machine asset that already owns the number a PC will report."""
    from shopdb.core.models import AssetType, Asset, RelationshipType

    mt = AssetType(assettype='machine', pluginname='machines',
                   tablename='machines', description='Machines')
    db.session.add(mt)
    db.session.add(RelationshipType(relationshiptype='controls',
                                    isdirectional=True))
    db.session.flush()
    asset = Asset(assetnumber='3015', name='Machine 3015',
                  assettypeid=mt.assettypeid, statusid=1)
    db.session.add(asset)
    db.session.commit()
    return asset


def _links(machineassetid, activeonly=True):
    from shopdb.core.models import AssetRelationship
    q = AssetRelationship.query.filter_by(targetassetid=machineassetid)
    if activeonly:
        q = q.filter_by(isactive=True)
    return q.all()


def _go_quiet(db, hostname, hours=48):
    """Backdate a PC's last report so it reads as gone from the floor.

    A PC pulled off a machine simply stops reporting, and there is no other
    signal. Tests that want a completed handover have to age the incumbent
    past MACHINE_CLAIM_QUIET_HOURS; without this it is a live PC and the
    challenger is correctly held off.
    """
    from datetime import datetime, timedelta, timezone
    from plugins.computers.models import Computer

    comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
    comp.lastreporteddate = (datetime.now(timezone.utc).replace(tzinfo=None)
                             - timedelta(hours=hours))
    db.session.commit()
    return comp


def _enable_machinelink_alerts(db):
    """Turn the PC-to-machine alerts on.

    They ship OFF: several part markers legitimately share one machine number
    (0613, 0615, WJPRT), so on this site the alerts would fire on correct data.
    A test that wants to see one has to say so.
    """
    from shopdb.core.models import Setting
    db.session.add(Setting(key='computers_machinelink_alerts', value='true'))
    db.session.commit()


def _set_status(db, hostname, statusname):
    """Put a PC's asset on a named status, the way a person would in the UI."""
    from shopdb.core.models import AssetStatus
    from plugins.computers.models import Computer

    status = AssetStatus.query.filter_by(status=statusname).first()
    if not status:
        status = AssetStatus(status=statusname, isactive=True)
        db.session.add(status)
        db.session.flush()
    comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
    comp.asset.statusid = status.statusid
    db.session.commit()
    return comp


def test_pc_reporting_an_existing_machine_number_does_not_500(
        client, db, collector_key, computer_assettype, machine_3015):
    """The machine owns assetnumber 3015. Naming the PC 3015 too hit the unique
    index and returned 500 to the bay on every retry, forever."""
    r = client.post('/api/collector/computers',
                    json={'hostname': 'FFBWTH63', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})
    assert r.status_code == 200, r.get_json()


def test_pc_gets_its_own_assetnumber_not_the_machines(
        client, db, collector_key, computer_assettype, machine_3015):
    from plugins.computers.models import Computer
    client.post('/api/collector/computers',
                json={'hostname': 'FFBWTH63', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    comp = Computer.query.filter(Computer.hostname.ilike('FFBWTH63')).first()
    assert comp.asset.assetnumber == 'FFBWTH63'


def test_reported_machine_number_creates_the_link(
        client, db, collector_key, computer_assettype, machine_3015):
    client.post('/api/collector/computers',
                json={'hostname': 'FFBWTH63', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    assert len(_links(machine_3015.assetid)) == 1


def test_replacing_a_pc_hands_the_machine_over(
        client, db, collector_key, computer_assettype, machine_3015):
    """The point of the whole thing: the new PC takes the bay and the retired
    one stops being shown as its controller."""
    from shopdb.core.models import Asset
    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _go_quiet(db, 'OLDPC')
    r = client.post('/api/collector/computers',
                    json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})

    active = _links(machine_3015.assetid)
    assert len(active) == 1
    owner = db.session.get(Asset, active[0].sourceassetid)
    assert owner.assetnumber == 'NEWPC'

    # and it says so, so a person can go and look at the old one
    assert any('taken over' in w for w in r.get_json()['data']['warnings'])


def test_a_live_pc_keeps_its_machine_against_a_new_claim(
        client, db, collector_key, computer_assettype, machine_3015):
    """A PC imaged for machine 3015 carries that number before it ever reaches
    the floor. While the PC actually running the machine is still reporting,
    the claim is not a handover and must not move the link."""
    from shopdb.core.models import Asset
    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    r = client.post('/api/collector/computers',
                    json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})

    active = _links(machine_3015.assetid)
    assert len(active) == 1
    assert db.session.get(Asset, active[0].sourceassetid).assetnumber == 'OLDPC'
    assert any('still held by' in w for w in r.get_json()['data']['warnings'])

    # The claim is recorded, dormant, so nothing about it is lost.
    assert len(_links(machine_3015.assetid, activeonly=False)) == 2


def test_a_contested_claim_does_not_flap_or_realert(
        client, db, collector_key, computer_assettype, machine_3015,
        monkeypatch):
    """Both PCs keep reporting on their own schedules. The old code let them
    trade the link on every pass and alerted each time."""
    from shopdb.core.models import Asset
    import shopdb.api as shopdbapi

    alerts = []
    monkeypatch.setattr(shopdbapi, 'send_alert',
                        lambda subj, html, **k: alerts.append(subj))
    _enable_machinelink_alerts(db)

    for _ in range(3):
        client.post('/api/collector/computers',
                    json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})
        client.post('/api/collector/computers',
                    json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})

    active = _links(machine_3015.assetid)
    assert len(active) == 1
    assert db.session.get(Asset, active[0].sourceassetid).assetnumber == 'OLDPC'
    assert len(alerts) == 1, 'contested claim must alert once, not every cycle'


def test_retiring_the_old_pc_hands_the_machine_over_at_once(
        client, db, collector_key, computer_assettype, machine_3015):
    """The one-step way to force a swap without waiting out the quiet window:
    a person says the old PC is no longer in use."""
    from shopdb.core.models import Asset
    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    client.post('/api/collector/computers',
                json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _set_status(db, 'OLDPC', 'Retired')

    client.post('/api/collector/computers',
                json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})

    active = _links(machine_3015.assetid)
    assert len(active) == 1
    assert db.session.get(Asset, active[0].sourceassetid).assetnumber == 'NEWPC'


def test_a_pc_off_overnight_does_not_lose_its_machine(
        client, db, collector_key, computer_assettype, machine_3015):
    """Shorter than the quiet window is not gone. A machine must not change
    hands because a PC was switched off for the night or the network dropped."""
    from shopdb.core.models import Asset
    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _go_quiet(db, 'OLDPC', hours=14)
    client.post('/api/collector/computers',
                json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})

    active = _links(machine_3015.assetid)
    assert len(active) == 1
    assert db.session.get(Asset, active[0].sourceassetid).assetnumber == 'OLDPC'


def test_the_old_link_is_archived_not_deleted(
        client, db, collector_key, computer_assettype, machine_3015):
    """History has to survive: 'which PC ran 3015 in June' stays answerable."""
    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _go_quiet(db, 'OLDPC')
    client.post('/api/collector/computers',
                json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    assert len(_links(machine_3015.assetid, activeonly=False)) == 2


def test_the_old_pc_status_is_left_alone(
        client, db, collector_key, computer_assettype, machine_3015):
    """The collector cannot tell if it was shelved, broken or re-imaged, so it
    must not guess - and must not overwrite what a person set."""
    from plugins.computers.models import Computer
    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    old = Computer.query.filter(Computer.hostname.ilike('OLDPC')).first()
    before = old.asset.statusid
    _go_quiet(db, 'OLDPC')
    client.post('/api/collector/computers',
                json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    db.session.refresh(old.asset)
    assert old.asset.statusid == before


def test_unknown_machine_number_warns_and_invents_nothing(
        client, db, collector_key, computer_assettype, machine_3015):
    from shopdb.core.models import Asset
    r = client.post('/api/collector/computers',
                    json={'hostname': 'FFBWTH63', 'machinenumber': '9911'},
                    headers={'X-API-Key': collector_key})
    assert any('9911' in w for w in r.get_json()['data']['warnings'])
    assert Asset.query.filter(Asset.assetnumber == '9911').first() is None


def test_the_placeholder_machine_number_links_nothing(
        client, db, collector_key, computer_assettype, machine_3015):
    """9999 is the imaging-time placeholder, not a real bay."""
    r = client.post('/api/collector/computers',
                    json={'hostname': 'FFBWTH63', 'machinenumber': '9999'},
                    headers={'X-API-Key': collector_key})
    assert r.status_code == 200
    assert _links(machine_3015.assetid) == []


def test_supersede_alerts_email_and_webhook(client, db, collector_key,
                                            computer_assettype, machine_3015,
                                            monkeypatch):
    """A person has to find out. Email goes to the site alert_recipients and the
    webhook fires, the same path the toner alerts use."""
    from shopdb.core.models import Setting
    import shopdb.api as shopdbapi

    # Patch on shopdb.api, which is where the plugin imports them from - the
    # names are bound there at import time, so patching shopdb.utils.mailer
    # would silently have no effect and the real (disabled) mailer would run.
    # send_alert is the site's configured fan-out: alert_recipients by email
    # plus the alert webhook. Patch the fan-out, not its two legs - resolving
    # recipients here by hand is exactly the bug this replaced.
    sent = {}
    monkeypatch.setattr(shopdbapi, 'send_alert',
                        lambda subj, html, **k: sent.update(alert=(subj, html)))
    db.session.add(Setting(key='alert_recipients', value='it@example.com'))
    db.session.add(Setting(key='site_base_url', value='https://shopdb/shopdb'))
    db.session.commit()
    _enable_machinelink_alerts(db)

    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _go_quiet(db, 'OLDPC')
    client.post('/api/collector/computers',
                json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})

    assert 'alert' in sent, 'no alert raised on supersede'
    subject, html = sent['alert']
    assert '3015' in subject and 'OLDPC' in subject and 'NEWPC' in subject
    # Must be the handover alert, not the contested-claim one. Both name the
    # same three things, so the subjects alone do not tell them apart.
    assert 'replaced' in subject, subject

    # The link must be keyed on computerid: /pcs/:id resolves through
    # GET /api/computers/<computerid>, so an assetid opens the wrong PC.
    from plugins.computers.models import Computer
    newpc = Computer.query.filter_by(hostname='NEWPC').first()
    assert '/pcs/{}"'.format(newpc.computerid) in html


def test_alert_failure_never_breaks_the_collector(client, db, collector_key,
                                                  computer_assettype,
                                                  machine_3015, monkeypatch):
    """A broken mail server must not stop a bay reporting its inventory."""
    import shopdb.api as shopdbapi

    def boom(*a, **k):
        raise RuntimeError('smtp down')
    monkeypatch.setattr(shopdbapi, 'send_alert', boom)
    _enable_machinelink_alerts(db)

    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _go_quiet(db, 'OLDPC')
    r = client.post('/api/collector/computers',
                    json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})
    assert r.status_code == 200
    assert len(_links(machine_3015.assetid)) == 1


def test_machinelink_alerts_are_off_by_default(client, db, collector_key,
                                               computer_assettype,
                                               machine_3015, monkeypatch):
    """Several part markers legitimately share a machine number (0613, 0615,
    WJPRT), so on this site a handover alert would fire on correct data. The
    link, the archive and the collector warning all still happen - only the
    sending is gated, and it ships off."""
    import shopdb.api as shopdbapi

    alerts = []
    monkeypatch.setattr(shopdbapi, 'send_alert',
                        lambda subj, html, **k: alerts.append(subj))

    client.post('/api/collector/computers',
                json={'hostname': 'OLDPC', 'machinenumber': '3015'},
                headers={'X-API-Key': collector_key})
    _go_quiet(db, 'OLDPC')
    r = client.post('/api/collector/computers',
                    json={'hostname': 'NEWPC', 'machinenumber': '3015'},
                    headers={'X-API-Key': collector_key})

    assert alerts == []
    assert len(_links(machine_3015.assetid)) == 1
    assert any('taken over' in w for w in r.get_json()['data']['warnings'])


# =============================================================================
# Part markers: a marker is its own asset, under the operation it serves
# =============================================================================

@pytest.fixture
def operation_0615(db, machine_3015):
    """An operation number that more than one part marker serves.

    machine_3015 is depended on for the 'machine' asset type, the 'controls'
    relationship type and 'partof', which the marker links need.
    """
    from shopdb.core.models import AssetType, Asset, RelationshipType

    db.session.add(RelationshipType(relationshiptype='partof',
                                    isdirectional=True))
    mt = AssetType.query.filter_by(assettype='machine').first()
    asset = Asset(assetnumber='0615', name='Operation 0615',
                  assettypeid=mt.assettypeid, statusid=1)
    db.session.add(asset)
    db.session.commit()
    return asset


def _marker(hostname):
    return {'hostname': hostname, 'machinenumber': '0615',
            'pctype': 'gea-shopfloor-partmarker'}


def test_a_marker_pc_gets_a_marker_asset_of_its_own(
        client, db, collector_key, computer_assettype, operation_0615):
    from shopdb.core.models import Asset
    client.post('/api/collector/computers', json=_marker('MARKERPC1'),
                headers={'X-API-Key': collector_key})

    marker = Asset.query.filter_by(assetnumber='MARKERPC1-PARTMARKER').first()
    assert marker is not None
    assert marker.machine is not None
    assert marker.machine.machinetype.machinetype == 'Part Marker'


def test_two_markers_share_an_operation_without_contesting_it(
        client, db, collector_key, computer_assettype, operation_0615):
    """The reason markers became assets. Both markers serve 0615, and an
    operation holds any number of them, so neither takes the other's place."""
    from shopdb.core.models import Asset, AssetRelationship, RelationshipType
    for host in ('MARKERPC1', 'MARKERPC2'):
        client.post('/api/collector/computers', json=_marker(host),
                    headers={'X-API-Key': collector_key})

    partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
    members = AssetRelationship.query.filter_by(
        targetassetid=operation_0615.assetid,
        relationshiptypeid=partof.relationshiptypeid,
        isactive=True).all()
    assert len(members) == 2

    names = sorted(db.session.get(Asset, rel.sourceassetid).assetnumber
                   for rel in members)
    assert names == ['MARKERPC1-PARTMARKER', 'MARKERPC2-PARTMARKER']


def test_a_marker_pc_does_not_claim_the_operation_directly(
        client, db, collector_key, computer_assettype, operation_0615):
    """Control of the operation follows the partof rail from the marker. A
    direct PC claim would be the link two markers cannot both hold."""
    from shopdb.core.models import AssetRelationship
    client.post('/api/collector/computers', json=_marker('MARKERPC1'),
                headers={'X-API-Key': collector_key})

    direct = AssetRelationship.query.filter_by(
        targetassetid=operation_0615.assetid,
        label='collector:machine').all()
    assert direct == []


def test_reporting_again_reuses_the_marker(
        client, db, collector_key, computer_assettype, operation_0615):
    """GE-Enforce posts every cycle; a marker must not be minted each time."""
    from shopdb.core.models import Asset
    for _ in range(3):
        client.post('/api/collector/computers', json=_marker('MARKERPC1'),
                    headers={'X-API-Key': collector_key})
    markers = Asset.query.filter(
        Asset.assetnumber.like('%-PARTMARKER')).all()
    assert len(markers) == 1


def test_moving_a_marker_archives_the_old_operation(
        client, db, collector_key, computer_assettype, operation_0615,
        machine_3015):
    """Where a marker used to live stays answerable."""
    from shopdb.core.models import AssetRelationship, RelationshipType
    client.post('/api/collector/computers', json=_marker('MARKERPC1'),
                headers={'X-API-Key': collector_key})
    moved = dict(_marker('MARKERPC1'), machinenumber='3015')
    client.post('/api/collector/computers', json=moved,
                headers={'X-API-Key': collector_key})

    partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
    rows = AssetRelationship.query.filter_by(
        relationshiptypeid=partof.relationshiptypeid).all()
    active = [r for r in rows if r.isactive]
    assert len(rows) == 2, 'the old membership must be kept, not deleted'
    assert len(active) == 1
    assert active[0].targetassetid == machine_3015.assetid


def test_an_ordinary_pc_gets_no_marker(
        client, db, collector_key, computer_assettype, operation_0615):
    from shopdb.core.models import Asset
    client.post('/api/collector/computers',
                json={'hostname': 'PLAINPC', 'machinenumber': '0615',
                      'pctype': 'gea-shopfloor-collections'},
                headers={'X-API-Key': collector_key})
    assert Asset.query.filter(Asset.assetnumber.like('%-PARTMARKER')).all() == []


def test_a_marker_is_never_filed_under_its_own_pc(
        client, db, collector_key, computer_assettype, machine_3015):
    """A PC first seen before the machine-number fix was created with the
    machine number as its OWN asset number, and that is never overwritten. So
    resolving the number can return the PC itself, and filing the marker partof
    its own PC reads on the machine page as the PC being the operation."""
    from shopdb.core.models import Asset, AssetRelationship, RelationshipType
    from plugins.computers.models import Computer

    db.session.add(RelationshipType(relationshiptype='partof',
                                    isdirectional=True))
    db.session.commit()

    # A legacy PC: its own asset carries the machine number, and no separate
    # asset for the operation exists.
    client.post('/api/collector/computers',
                json={'hostname': 'LEGACYPC',
                      'pctype': 'gea-shopfloor-partmarker'},
                headers={'X-API-Key': collector_key})
    comp = Computer.query.filter(Computer.hostname.ilike('LEGACYPC')).first()
    comp.asset.assetnumber = '0615'
    db.session.commit()

    r = client.post('/api/collector/computers',
                    json={'hostname': 'LEGACYPC', 'machinenumber': '0615',
                          'pctype': 'gea-shopfloor-partmarker'},
                    headers={'X-API-Key': collector_key})

    partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
    links = AssetRelationship.query.filter_by(
        relationshiptypeid=partof.relationshiptypeid, isactive=True).all()
    targets = [db.session.get(Asset, rel.targetassetid).assetid
               for rel in links]
    assert comp.assetid not in targets, 'marker filed under its own PC'
    assert any("own asset number" in w
               for w in r.get_json()['data']['warnings'])


# =============================================================================
# The subordinate-device map: adding a device type is configuration, not code
# =============================================================================

def test_a_site_can_add_a_device_type_without_a_code_change(
        client, db, collector_key, computer_assettype, operation_0615):
    """The point of the map. A site with two marking lasers on one operation
    number needs the same treatment part markers got; before this it needed an
    edit to the plugin."""
    import json
    from shopdb.core.models import Setting, Asset

    db.session.add(Setting(
        key='subordinatedevice_site-laser',
        value=json.dumps({'assettype': 'machine', 'typename': 'Marking Laser',
                          'description': 'Site laser', 'suffix': 'LASER',
                          'partof': True, 'label': 'collector:partmarker'}),
        category='pctypemapping'))
    db.session.commit()

    client.post('/api/collector/computers',
                json={'hostname': 'LASERPC1', 'machinenumber': '0615',
                      'pctype': 'site-laser'},
                headers={'X-API-Key': collector_key})

    device = Asset.query.filter_by(assetnumber='LASERPC1-LASER').first()
    assert device is not None
    assert device.machine.machinetype.machinetype == 'Marking Laser'


def test_a_malformed_override_does_not_break_the_collector(
        client, db, collector_key, computer_assettype, operation_0615):
    """A bad setting must not stop a bay reporting its inventory."""
    from shopdb.core.models import Setting

    db.session.add(Setting(key='subordinatedevice_gea-shopfloor-partmarker',
                           value='{not json', category='pctypemapping'))
    db.session.commit()

    r = client.post('/api/collector/computers', json=_marker('MARKERPC9'),
                    headers={'X-API-Key': collector_key})
    assert r.status_code == 200, r.get_json()


def test_the_metrology_map_still_answers_through_the_shim(app):
    """filters.py and the older tests read metrology_tool_for. Unifying the two
    maps must not change what it returns."""
    with app.app_context():
        from plugins.computers.pctypemap import metrology_tool_for
        assert metrology_tool_for('gea-shopfloor-cmm') == (
            'CMM', 'Coordinate measuring machine')
        # A machine-typed device is not a measuring tool.
        assert metrology_tool_for('gea-shopfloor-partmarker') is None
        assert metrology_tool_for('gea-shopfloor-collections') is None
