"""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
        assert comp.asset.assetnumber == '0615'          # machinenumber -> assetnumber
        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
