"""GE-Enforce observed-state reporting: PCs POST enforcement results.

Covers the report ingest (applied version + per-entry self-heal outcomes), the
latest-per-host upsert, received-latest derivation (did the PC pick up the newest
published manifest?), status derivation (ok / selfhealed / failed), and the admin
fleet-compliance views.
"""

import json

from plugins.geenforce import service


SCOPE = {
    'Version': '2.6',
    'Applications': [
        {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'},
        {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'},
    ],
}


def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
    with app.app_context():
        service.replace_scope_draft(scopename, 'runtime', SCOPE)
        version = service.publish_scope(scopename, 'runtime', notes='v')
        service.db.session.commit()
    return version


def _token(client, auth_headers, scopes):
    resp = client.post('/api/apitokens',
                       json={'name': 'svc', 'scopes': scopes},
                       headers=auth_headers)
    assert resp.status_code == 201, resp.get_json()
    return resp.get_json()['data']['secret']


def test_report_malformed_counts_is_400(client, db, app, auth_headers):
    """A wrong JSON shape (counts as a string, results as a string) must be a
    clean 400, not a 500 from a later .get()/iteration."""
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    for bad in ({'counts': 'nope'}, {'results': 'nope'}):
        body = {'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm'}
        body.update(bad)
        resp = client.post('/api/geenforce/report', json=body,
                           headers={'X-API-Key': secret})
        assert resp.status_code == 400, resp.get_json()


def test_report_recorded_and_listed(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    resp = client.post('/api/geenforce/report', json={
        'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
        'appliedversion': 1, 'enforcerversion': '2.6',
        'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0},
        'results': [
            {'name': 'Alpha', 'action': 'installed', 'selfhealed': True},
            {'name': 'Beta', 'action': 'skipped'},
        ],
    }, headers={'X-API-Key': secret})
    assert resp.status_code == 200, resp.get_json()
    assert resp.get_json()['data']['status'] == 'selfhealed'

    listing = client.get('/api/geenforce/reports', headers=auth_headers)
    rows = listing.get_json()['data']
    assert len(rows) == 1
    row = rows[0]
    assert row['hostname'] == 'WJCMM01'
    assert row['appliedversion'] == 1
    assert row['latestversion'] == 1
    assert row['receivedlatest'] is True
    assert row['installed'] == 1 and row['skipped'] == 1


def test_received_latest_flips_when_new_version_published(client, db, app,
                                                          auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    # enforcerversion matters here: the publish gate refuses to ship a 2.6
    # manifest to a PC that cannot prove it runs at least lib 2.6, and a report
    # with no version is treated as an old lib.
    client.post('/api/geenforce/report',
                json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
                      'appliedversion': 1, 'counts': {},
                      'enforcerversion': '2.6'},
                headers={'X-API-Key': secret})
    # Publish a newer version; the PC is now behind.
    with app.app_context():
        service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2')
        service.db.session.commit()

    row = client.get('/api/geenforce/reports?hostname=WJCMM02',
                     headers=auth_headers).get_json()['data'][0]
    assert row['appliedversion'] == 1
    assert row['latestversion'] == 2
    assert row['receivedlatest'] is False


def test_latest_report_upserts_per_host(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    for installed in (0, 1):
        client.post('/api/geenforce/report',
                    json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm',
                          'appliedversion': 1,
                          'counts': {'installed': installed}},
                    headers={'X-API-Key': secret})
    rows = client.get('/api/geenforce/reports?hostname=WJCMM03',
                      headers=auth_headers).get_json()['data']
    assert len(rows) == 1  # only the latest is current
    assert rows[0]['installed'] == 1


def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers):
    """A PC reporting its hostname in different casing must not leave two
    iscurrent rows (the demote is case-insensitive, matching the ilike read)."""
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    for host in ('WJCMM09', 'wjcmm09'):
        client.post('/api/geenforce/report',
                    json={'hostname': host, 'scopename': 'gea-shopfloor-cmm',
                          'appliedversion': 1, 'counts': {'installed': 1}},
                    headers={'X-API-Key': secret})
    from plugins.geenforce.models import ManifestEnforcementReport
    with app.app_context():
        current = ManifestEnforcementReport.query.filter(
            ManifestEnforcementReport.hostname.ilike('WJCMM09'),
            ManifestEnforcementReport.iscurrent == True).all()
        assert len(current) == 1


def test_status_failed_when_failures(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    resp = client.post('/api/geenforce/report',
                       json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm',
                             'counts': {'failed': 1},
                             'results': [{'name': 'Alpha', 'action': 'failed',
                                          'exitcode': 1603,
                                          'message': 'MSI 1603'}]},
                       headers={'X-API-Key': secret})
    assert resp.get_json()['data']['status'] == 'failed'


def test_report_detail_shows_per_entry_results(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    post = client.post('/api/geenforce/report',
                       json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm',
                             'results': [
                                 {'name': 'Alpha', 'action': 'installed',
                                  'selfhealed': True},
                                 {'name': 'Beta', 'action': 'failed',
                                  'exitcode': 1, 'message': 'boom'}]},
                       headers={'X-API-Key': secret})
    reportid = post.get_json()['data']['reportid']
    detail = client.get(f'/api/geenforce/reports/{reportid}',
                        headers=auth_headers).get_json()['data']
    results = {r['entryname']: r for r in detail['results']}
    assert results['Alpha']['selfhealed'] is True
    assert results['Beta']['action'] == 'failed'
    assert results['Beta']['message'] == 'boom'


def test_report_requires_report_scope(client, db, app, auth_headers):
    _seed_and_publish(app)
    # A fetch-only token cannot report.
    secret = _token(client, auth_headers, ['geenforce.fetch'])
    resp = client.post('/api/geenforce/report',
                       json={'hostname': 'WJCMM06'},
                       headers={'X-API-Key': secret})
    assert resp.status_code == 401


def test_report_unauthenticated_rejected(client, db, app):
    resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'})
    assert resp.status_code == 401


def test_report_missing_hostname_rejected(client, db, app, auth_headers):
    secret = _token(client, auth_headers, ['geenforce.report'])
    resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
                       headers={'X-API-Key': secret})
    assert resp.status_code == 400


# -- going quiet ------------------------------------------------------------
#
# A report records how one cycle went; nothing ages it. A PC switched off after
# a clean run therefore kept 'ok' indefinitely and read as healthy - which is
# how a machine offline for over a day still showed 'ok'.

def _age_report(db, hostname, minutes):
    """Push a stored report's server-side receivedat back in time."""
    from datetime import timedelta
    from plugins.geenforce.models import ManifestEnforcementReport
    from plugins.geenforce.service import _utcnow
    (ManifestEnforcementReport.query
     .filter_by(hostname=hostname, iscurrent=True)
     .update({'receivedat': _utcnow() - timedelta(minutes=minutes)}))
    db.session.commit()


def _post_clean_report(client, secret, hostname):
    return client.post('/api/geenforce/report', json={
        'hostname': hostname, 'scopename': 'gea-shopfloor-cmm',
        'appliedversion': 1, 'enforcerversion': '2.6',
        'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
        'results': [{'name': 'Alpha', 'action': 'installed'}],
    }, headers={'X-API-Key': secret})


def test_a_pc_reporting_now_is_not_stale(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _post_clean_report(client, secret, 'WJCMM01')

    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['status'] == 'ok'
    assert row['isstale'] is False
    assert row['staleafterminutes'] == 30


def test_a_pc_that_stopped_reporting_goes_stale(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _post_clean_report(client, secret, 'WJCMM01')
    _age_report(db, 'WJCMM01', minutes=60 * 26)  # offline over a day

    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['isstale'] is True
    # The REPORTED status is left alone - it is still a true record of the last
    # cycle, and the UI shows it in the tooltip behind the stale badge.
    assert row['status'] == 'ok'


def test_stale_boundary_is_the_configured_threshold(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _post_clean_report(client, secret, 'WJCMM01')

    _age_report(db, 'WJCMM01', minutes=29)
    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['isstale'] is False, 'inside the window is not stale'

    _age_report(db, 'WJCMM01', minutes=31)
    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['isstale'] is True, 'past the window is stale'


def test_stale_threshold_is_a_setting(client, db, app, auth_headers):
    from shopdb.api import Setting
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _post_clean_report(client, secret, 'WJCMM01')
    _age_report(db, 'WJCMM01', minutes=45)

    Setting.set('geenforce_reportstaleminutes', '120', valuetype='integer',
                category='geenforce')
    db.session.commit()
    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['isstale'] is False
    assert row['staleafterminutes'] == 120


def test_zero_disables_the_stale_check(client, db, app, auth_headers):
    from shopdb.api import Setting
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _post_clean_report(client, secret, 'WJCMM01')
    _age_report(db, 'WJCMM01', minutes=60 * 24 * 7)

    Setting.set('geenforce_reportstaleminutes', '0', valuetype='integer',
                category='geenforce')
    db.session.commit()
    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['isstale'] is False
    assert row['staleafterminutes'] == 0


def test_a_pc_still_posting_with_a_stalled_checkin_is_stale(client, db, app,
                                                            auth_headers):
    """The other way round: the server hears from it, but its own cycle stopped.

    receivedat stays current while the PC's self-reported lastcheckin does not
    advance. The fleet table has a column for each, and either going quiet is a
    problem worth seeing.
    """
    from datetime import timedelta
    from plugins.geenforce.service import _utcnow
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    client.post('/api/geenforce/report', json={
        'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
        'appliedversion': 1, 'enforcerversion': '2.6',
        'lastcheckin': (_utcnow() - timedelta(hours=22)).isoformat() + 'Z',
        'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
        'results': [{'name': 'Alpha', 'action': 'installed'}],
    }, headers={'X-API-Key': secret})

    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['status'] == 'ok', 'the cycle itself reported clean'
    assert row['isstale'] is True, 'but it has not checked in for 22 hours'


def test_a_future_checkin_cannot_mask_server_side_silence(client, db, app,
                                                          auth_headers):
    """lastcheckin is client-asserted, so it must not be able to vouch for a PC.

    Taking the newer of the two timestamps would let a PC with a wrong clock
    report itself healthy forever. receivedat is checked on its own.
    """
    from datetime import timedelta
    from plugins.geenforce.service import _utcnow
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    client.post('/api/geenforce/report', json={
        'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
        'appliedversion': 1, 'enforcerversion': '2.6',
        'lastcheckin': (_utcnow() + timedelta(days=3)).isoformat() + 'Z',
        'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
        'results': [{'name': 'Alpha', 'action': 'installed'}],
    }, headers={'X-API-Key': secret})
    _age_report(db, 'WJCMM01', minutes=60 * 26)

    row = client.get('/api/geenforce/reports',
                     headers=auth_headers).get_json()['data'][0]
    assert row['isstale'] is True


def test_asset_link_uses_the_plugin_id_not_the_assetid(client, db, app, auth_headers):
    """/machines/:id keys on machineid, NOT the core assetid.

    Returning the assetid made the fleet table link to whichever machine
    happened to carry that number - a wrong page that looks right rather than a
    404. The frontend links on machinepluginid for exactly this reason.
    """
    from shopdb.core.models import Asset, AssetType
    from shopdb.core.models.relationship import RelationshipType, AssetRelationship
    from plugins.computers.models import Computer
    from plugins.machines.models import Machine

    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])

    computertype = AssetType.query.filter_by(assettype='computer').first() \
        or AssetType(assettype='computer')
    machinetype = AssetType.query.filter_by(assettype='machine').first() \
        or AssetType(assettype='machine')
    db.session.add_all([computertype, machinetype])
    db.session.flush()

    pcasset = Asset(assetnumber='WJBAY01', assettypeid=computertype.assettypeid)
    machineasset = Asset(assetnumber='6905', assettypeid=machinetype.assettypeid)
    db.session.add_all([pcasset, machineasset])
    db.session.flush()
    db.session.add(Computer(assetid=pcasset.assetid, hostname='WJBAY01'))
    machine = Machine(assetid=machineasset.assetid)
    db.session.add(machine)
    db.session.flush()

    controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
    if not controls:
        controls = RelationshipType(relationshiptype='controls')
        db.session.add(controls)
        db.session.flush()
    db.session.add(AssetRelationship(
        sourceassetid=pcasset.assetid, targetassetid=machineasset.assetid,
        relationshiptypeid=controls.relationshiptypeid, label='collector:machine'))
    db.session.commit()

    client.post('/api/geenforce/report', json={
        'hostname': 'WJBAY01', 'scopename': 'gea-shopfloor-cmm', 'counts': {},
    }, headers={'X-API-Key': secret})

    row = [r for r in client.get('/api/geenforce/reports',
                                 headers=auth_headers).get_json()['data']
           if r['hostname'] == 'WJBAY01'][0]
    assert row['machinenumber'] == '6905'
    assert row['machineassetid'] == machineasset.assetid
    assert row['machinepluginid'] == machine.machineid


# -- backup verdict ----------------------------------------------------------
#
# The column used to show a raw date, which read as neglect: dedup means an
# unchanged config writes no new revision, so a day-old confirmation IS the
# healthy steady state at the default 24h collection interval.

def _backup_revision(db, hostname, kind, ageda):
    from datetime import timedelta
    from plugins.backups.models import BackupRevision
    from plugins.geenforce.service import _utcnow
    from shopdb.core.models import Asset, AssetType
    from plugins.computers.models import Computer

    computertype = AssetType.query.filter_by(assettype='computer').first() \
        or AssetType(assettype='computer')
    db.session.add(computertype)
    db.session.flush()
    asset = Asset(assetnumber=hostname, assettypeid=computertype.assettypeid)
    db.session.add(asset)
    db.session.flush()
    db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
    db.session.add(BackupRevision(
        assetid=asset.assetid, backupkind=kind, sourcehostname=hostname,
        contenthash='0' * 64,
        # captured well before the last confirmation on purpose: an unchanged
        # config is the normal case, and the two dates must not be conflated
        collectedat=_utcnow() - timedelta(days=ageda + 30),
        lastseenat=_utcnow() - timedelta(days=ageda)))
    db.session.commit()


def _row_for(client, auth_headers, hostname):
    return [r for r in client.get('/api/geenforce/reports',
                                  headers=auth_headers).get_json()['data']
            if r['hostname'] == hostname][0]


def test_a_recent_check_is_good_even_though_nothing_changed(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _backup_revision(db, 'WJBAK01', 'ntlars', ageda=1)
    client.post('/api/geenforce/report', json={
        'hostname': 'WJBAK01', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
        headers={'X-API-Key': secret})

    row = _row_for(client, auth_headers, 'WJBAK01')
    assert row['backupkind'] == 'ntlars'
    assert row['backupok'] is True, 'a day old is the healthy steady state'
    assert row['backupstaleafterdays'] == 3


def test_a_check_older_than_the_threshold_is_stale(client, db, app, auth_headers):
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _backup_revision(db, 'WJBAK02', 'ntlars', ageda=9)
    client.post('/api/geenforce/report', json={
        'hostname': 'WJBAK02', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
        headers={'X-API-Key': secret})

    assert _row_for(client, auth_headers, 'WJBAK02')['backupok'] is False


def test_no_backup_at_all_is_unknown_not_good(client, db, app, auth_headers):
    """None, never True: 'never seen' must not render as a green badge."""
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    client.post('/api/geenforce/report', json={
        'hostname': 'WJBAK03', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
        headers={'X-API-Key': secret})

    row = _row_for(client, auth_headers, 'WJBAK03')
    assert row['backupkind'] is None
    assert row['backupok'] is None


def test_the_capture_date_is_reported_separately_from_the_check(client, db, app, auth_headers):
    """Two dates, two meanings: when it was last CHECKED vs last CAPTURED.

    collectedat only moves when the config actually changed, so a healthy
    machine has a recent check against an old capture. The hover says both;
    one date would have to stand for both and ends up meaning neither.
    """
    _seed_and_publish(app)
    secret = _token(client, auth_headers, ['geenforce.report'])
    _backup_revision(db, 'WJBAK04', 'ntlars', ageda=1)
    client.post('/api/geenforce/report', json={
        'hostname': 'WJBAK04', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
        headers={'X-API-Key': secret})

    row = _row_for(client, auth_headers, 'WJBAK04')
    assert row['backupok'] is True
    assert row['backuplastseen'] is not None
    assert row['backupcollectedat'] is not None
    assert row['backupcollectedat'] < row['backuplastseen'], \
        'captured before it was last confirmed'
