"""Publishing must not outrun the enforcer lib deployed on the fleet.

THE FAILURE THIS PREVENTS: the engine treats a minor-newer manifest as backward
compatible and carries on. That is true for additions that WIDEN behaviour (an
old lib skips a Type it does not know) and false for one that NARROWS it. The
_CmmVersion filter shipped in lib 2.6 as a minor bump, so a PC on lib 2.5 does
not recognise the field, reads every gated entry as unfiltered, and installs
EVERY PC-DMIS version it cannot detect - on every CMM, within one cycle.

The share runbook says "push the lib first". A runbook is not a control.
"""

from datetime import datetime, timezone

import pytest

from plugins.geenforce import service
from plugins.geenforce.models import ManifestScope, ManifestEnforcementReport
from shopdb.api import db


def _scope(manifestversion='2.6', scopename='gea-shopfloor-cmm'):
    scope = ManifestScope(scopename=scopename, phase='runtime',
                          manifestversion=manifestversion)
    db.session.add(scope)
    db.session.flush()
    return scope


def _report(hostname, enforcerversion, scopename='gea-shopfloor-cmm'):
    report = ManifestEnforcementReport(
        hostname=hostname, scopename=scopename, phase='runtime',
        enforcerversion=enforcerversion, receivedat=datetime.now(timezone.utc).replace(tzinfo=None),
        iscurrent=True)
    db.session.add(report)
    db.session.flush()
    return report


# -- version parsing ---------------------------------------------------------

@pytest.mark.parametrize('text,expected', [
    ('2.6', (2, 6)),
    ('2.10', (2, 10)),      # 10 > 6, not string-ordered
    ('3', (3, 0)),
    (' 2.6 ', (2, 6)),
    (None, None),
    ('', None),
    ('unknown', None),
    ('2.x', None),
])
def test_parse_libversion(text, expected):
    assert service.parse_libversion(text) == expected


def test_minor_versions_compare_numerically_not_as_strings():
    """'2.10' vs '2.9' is the classic string-compare trap: as text '2.10' sorts
    BELOW '2.9' and a current lib would look out of date."""
    assert service.parse_libversion('2.10') > service.parse_libversion('2.9')


# -- the gate ----------------------------------------------------------------

def test_publish_refused_when_a_cmm_runs_an_older_lib(db):
    """The real scenario: cmm manifest 2.6, one bay still on 2.5."""
    _scope('2.6')
    _report('CMMBAY01', '2.6')
    _report('CMMBAY02', '2.5')

    with pytest.raises(service.LibVersionTooOldError) as caught:
        service.publish_scope('gea-shopfloor-cmm', 'runtime')

    error = caught.value
    assert error.hosts == ['CMMBAY02']
    assert error.required == '2.6'
    assert 'CMMBAY02' in str(error)
    # The message has to say what to do, not just that it said no.
    assert 'lib' in str(error).lower()


def test_publish_allowed_when_the_whole_fleet_is_current(db):
    _scope('2.6')
    _report('CMMBAY01', '2.6')
    _report('CMMBAY02', '2.7')
    assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1


def test_a_missing_enforcerversion_counts_as_behind(db):
    """The field arrived with the summary-emitting engine, so its absence means
    an old lib. Treating unknown as safe is how this fails open."""
    _scope('2.6')
    _report('CMMBAY01', None)
    with pytest.raises(service.LibVersionTooOldError) as caught:
        service.publish_scope('gea-shopfloor-cmm', 'runtime')
    assert caught.value.hosts == ['CMMBAY01']


def test_an_unparseable_enforcerversion_counts_as_behind(db):
    _scope('2.6')
    _report('CMMBAY01', 'garbage')
    with pytest.raises(service.LibVersionTooOldError):
        service.publish_scope('gea-shopfloor-cmm', 'runtime')


def test_force_publishes_anyway(db):
    """The gate is a guard rail, not a wall - someone who knows why can pass."""
    _scope('2.6')
    _report('CMMBAY02', '2.5')
    assert service.publish_scope(
        'gea-shopfloor-cmm', 'runtime', force=True) == 1


def test_a_scope_nobody_reports_for_still_publishes(db):
    """No reports = no floor to compare. A fresh site must not be unable to
    publish anything at all."""
    _scope('2.6')
    assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1


def test_other_scopes_reports_do_not_block_this_one(db):
    """A PC on an old lib reporting for 'common' says nothing about whether the
    cmm scope may publish - only PCs that receive THIS scope matter."""
    _scope('2.6')
    _report('OLDPC', '2.0', scopename='common')
    assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1


def test_superseded_reports_are_ignored(db):
    """Only the current report per host counts; a stale row from before a lib
    upgrade must not block forever."""
    _scope('2.6')
    old = _report('CMMBAY01', '2.5')
    old.iscurrent = False
    _report('CMMBAY01', '2.6')
    assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1


def test_an_unversioned_manifest_does_not_block(db):
    """A scope with no parseable manifestversion has nothing to require."""
    _scope('legacy')
    _report('CMMBAY01', '2.5')
    assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1


def test_hosts_below_libversion_reports_the_floor(db):
    _scope('2.6')
    _report('A', '2.5')
    _report('B', '2.3')
    _report('C', '2.6')
    hosts, floor = service.hosts_below_libversion(
        'gea-shopfloor-cmm', 'runtime', '2.6')
    assert hosts == ['A', 'B']
    assert floor == '2.3'


# -- backup state on the reports table ---------------------------------------

def test_backup_state_prefers_the_newest_confirmed_revision(db):
    """A host with several revisions shows the most recently CONFIRMED one, and
    a revision that has never been confirmed (lastseenat NULL) never wins.

    Ordering happens in Python on purpose. Doing it in SQL needs
    ORDER BY ... NULLS LAST, which SQLite accepts and MySQL rejects outright -
    so it passed every test here and returned a 500 against the real database.
    """
    from datetime import timedelta

    from plugins.backups.models import BackupRevision
    from plugins.geenforce.api.routes import _attach_backup_state
    from shopdb.api import Asset, AssetType

    assettype = AssetType.query.first()
    if not assettype:
        assettype = AssetType(assettype='computer')
        db.session.add(assettype)
        db.session.flush()
    asset = Asset(assetnumber='PC-1', name='PC-1',
                  assettypeid=assettype.assettypeid)
    db.session.add(asset)
    db.session.flush()

    now = datetime.now(timezone.utc).replace(tzinfo=None)
    for kind, lastseen in (('ntlars', now - timedelta(days=9)),
                           ('udc', now),
                           ('files', None)):
        db.session.add(BackupRevision(
            assetid=asset.assetid, backupkind=kind, contenthash=kind * 8,
            sourcehostname='WJPC01', lastseenat=lastseen, createdat=now))
    db.session.flush()

    facts = {'wjpc01': {'backupkind': None, 'backuplastseen': None}}
    _attach_backup_state(facts, {'WJPC01'})

    assert facts['wjpc01']['backupkind'] == 'udc'
    assert facts['wjpc01']['backuplastseen'] is not None


# -- reported display subtype -------------------------------------------------

def test_report_stores_the_subtype_the_device_reports(db):
    """A kiosk knows whether it is a Dashboard or a Lobby; it now says so.

    Previously the fleet table inferred this from the DashboardDefault fqdn
    mapping, which is empty unless somebody added a row per kiosk, so the
    column was blank for every display.
    """
    from plugins.geenforce.service import record_enforcement_report

    record_enforcement_report({'hostname': 'WJKIOSK01', 'scopename': 'gea-shopfloor-display',
                   'subtype': 'Lobby', 'counts': {}})
    db.session.flush()

    stored = ManifestEnforcementReport.query.filter_by(
        hostname='WJKIOSK01', iscurrent=True).one()
    assert stored.subtype == 'Lobby'


def test_report_without_a_subtype_stores_none(db):
    """Every non-display PC type has no display-type.txt, so it reports no
    subtype - that must land as NULL, not as an empty string."""
    from plugins.geenforce.service import record_enforcement_report

    record_enforcement_report({'hostname': 'WJPC77', 'scopename': 'common',
                   'subtype': '', 'counts': {}})
    db.session.flush()

    stored = ManifestEnforcementReport.query.filter_by(
        hostname='WJPC77', iscurrent=True).one()
    assert stored.subtype is None


def test_reported_subtype_is_normalised_to_the_role_vocabulary(db):
    """display-type.txt is hand-edited, so its casing drifts across the fleet.

    Normalising on the way in keeps the fleet table reading one vocabulary
    instead of whatever each kiosk happened to be typed as.
    """
    from plugins.geenforce.service import record_enforcement_report

    for index, reported in enumerate(('lobby', 'LOBBY', '  Lobby  ')):
        host = f'WJKIOSKCASE{index}'
        record_enforcement_report({'hostname': host,
                                   'scopename': 'gea-shopfloor-display',
                                   'subtype': reported, 'counts': {}})
        db.session.flush()
        stored = ManifestEnforcementReport.query.filter_by(
            hostname=host, iscurrent=True).one()
        assert stored.subtype == 'Lobby', reported


def test_reported_subtype_keeps_an_unknown_value_verbatim(db):
    """An unrecognised subtype is a typo on a kiosk or a role nobody declared.

    Both are worth SEEING in the fleet table, so the value is stored as sent
    rather than blanked or guessed at.
    """
    from plugins.geenforce.service import record_enforcement_report

    record_enforcement_report({'hostname': 'WJKIOSKODD', 'scopename': 'gea-shopfloor-display',
                               'subtype': 'Lobbby', 'counts': {}})
    db.session.flush()

    stored = ManifestEnforcementReport.query.filter_by(
        hostname='WJKIOSKODD', iscurrent=True).one()
    assert stored.subtype == 'Lobbby'


# -- what the gate compares against ------------------------------------------
#
# The gate used manifestversion as the library floor. That is the manifest's own
# 'Version' field: for a share-imported manifest it happens to share the
# library's numbering, but a code-authored scope sets it freely -
# seed_display_scope writes '2.0' - so the fleet was compared against a number
# that says nothing about library features, and the gate passed on the very
# scope that most needed it.

def test_a_declared_minlibversion_is_what_the_fleet_is_judged_against(db):
    scope = _scope(manifestversion='2.0')
    scope.minlibversion = '2.6'
    db.session.flush()
    assert service.required_libversion(scope) == '2.6'

    _report('KIOSK01', '2.4')
    with pytest.raises(service.LibVersionTooOldError):
        service.publish_scope('gea-shopfloor-cmm', 'runtime')


def test_the_floor_is_derived_from_the_features_the_manifest_uses(db):
    """A scope written before minlibversion existed is still gated on what it
    contains: a version-gated entry needs lib 2.6, whatever its own Version
    field claims."""
    from plugins.geenforce.models import ManifestEntry

    scope = _scope(manifestversion='2.0')
    scope.entries.append(ManifestEntry(
        name='PC-DMIS 2023.2', entrytype='MSI', sortorder=0,
        cmmversion='2023'))
    db.session.flush()
    assert service.required_libversion(scope) == '2.6'


def test_a_scope_using_nothing_special_falls_back_to_its_own_version(db):
    scope = _scope(manifestversion='2.4')
    assert service.required_libversion(scope) == '2.4'
