"""Tests for the measuringtools plugin.

The plugin ships default-disabled, so the shared session app does not register
its blueprint. This module builds its own app, registers the blueprint, runs the
plugin's on_install seeding, and restores the process-wide plugin_manager
singleton afterwards (create_app repoints it) so other test modules are
unaffected - the same snapshot/restore dance used by test_plugin_migrations.

Coverage: derived calibration status (all four buckets + the 30-day boundary),
type CRUD with the in-use delete guard, tool create/update in one merged
payload, and the calibration report shape.
"""

from datetime import date, timedelta

import pytest
from werkzeug.security import generate_password_hash

from shopdb import create_app
from shopdb.extensions import db as _db
from shopdb.plugins import plugin_manager
from plugins.measuringtools.models import (
    derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)


# =============================================================================
# Pure unit tests: derived status (no app needed)
# =============================================================================

def test_derive_status_unknown_when_no_date():
    assert derive_status(None) == 'unknown'


def test_derive_status_overdue_in_past():
    assert derive_status(date.today() - timedelta(days=1)) == 'overdue'


def test_derive_status_current_far_future():
    assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current'


def test_derive_status_duesoon_within_window():
    assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon'


def test_derive_status_duesoon_on_boundary():
    """Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive)."""
    assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'


def test_derive_status_duesoon_today():
    """Due today is not yet overdue - it falls in the due-soon window."""
    assert derive_status(date.today()) == 'duesoon'


# =============================================================================
# API tests (self-contained app with the blueprint registered)
# =============================================================================

@pytest.fixture(scope='module')
def mt_app():
    """A testing app with the measuringtools blueprint registered + seeded."""
    saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
             plugin_manager.loader, plugin_manager.migration_manager,
             plugin_manager._registered_prefixes)
    application = create_app('testing')

    from plugins.measuringtools.plugin import MeasuringToolsPlugin
    plugin = MeasuringToolsPlugin()
    pm = application.extensions['plugin_manager']
    if plugin.meta.api_prefix not in pm._registered_prefixes:
        pm._register_plugin_components(plugin)

    with application.app_context():
        _db.create_all()
        _seed(application, plugin)
        yield application
        _db.session.remove()
        _db.drop_all()

    (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
     plugin_manager.loader, plugin_manager.migration_manager,
     plugin_manager._registered_prefixes) = saved


def _seed(application, plugin):
    """Seed permissions, an admin, a status, and the plugin's own reference data."""
    from shopdb.core.models import Permission, User, Role, AssetStatus
    Permission.seed()
    role = Role(rolename='admin', description='Administrator')
    _db.session.add(role)
    _db.session.add(AssetStatus(status='In Use', description='In use'))
    _db.session.flush()
    user = User(username='testadmin', email='admin@test.local',
                passwordhash=generate_password_hash('testpass'))
    user.roles.append(role)
    _db.session.add(user)
    _db.session.commit()
    # Seeds AssetType 'measuring_tool' + the starter tool types.
    plugin.on_install(application)


@pytest.fixture
def client(mt_app):
    return mt_app.test_client()


@pytest.fixture
def auth_headers(client):
    response = client.post('/api/auth/login',
                           json={'username': 'testadmin', 'password': 'testpass'})
    assert response.status_code == 200, response.get_json()
    token = response.get_json()['data']['access_token']
    return {'Authorization': f'Bearer {token}'}


def _status_id(client):
    return 1  # only status seeded


# -- Type CRUD + in-use guard -------------------------------------------------

def test_starter_types_seeded(client):
    response = client.get('/api/measuringtools/types')
    assert response.status_code == 200
    names = {t['name'] for t in response.get_json()['data']}
    assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names


def test_type_create_requires_auth(client):
    response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
    assert response.status_code == 401


def test_type_crud_lifecycle(client, auth_headers):
    created = client.post('/api/measuringtools/types', headers=auth_headers,
                          json={'name': 'Pin Gage', 'description': 'Pin gage set',
                                'color': '#123456'})
    assert created.status_code == 201, created.get_json()
    type_id = created.get_json()['data']['measuringtooltypeid']

    updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers,
                         json={'description': 'Updated'})
    assert updated.status_code == 200
    assert updated.get_json()['data']['description'] == 'Updated'

    dup = client.post('/api/measuringtools/types', headers=auth_headers,
                      json={'name': 'Pin Gage'})
    assert dup.status_code == 409

    deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
    assert deleted.status_code == 200


def test_type_delete_blocked_when_in_use(client, auth_headers):
    type_created = client.post('/api/measuringtools/types', headers=auth_headers,
                               json={'name': 'Depth Gage'})
    type_id = type_created.get_json()['data']['measuringtooltypeid']
    tool_created = client.post('/api/measuringtools', headers=auth_headers,
                               json={'assetnumber': 'MT-INUSE-1',
                                     'measuringtooltypeid': type_id,
                                     'statusid': _status_id(client)})
    assert tool_created.status_code == 201, tool_created.get_json()

    blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
    assert blocked.status_code == 409
    assert 'still use this type' in blocked.get_json()['data']['error']['message']


# -- Tool create/update merged payload ----------------------------------------

def test_tool_create_and_get_merged(client, auth_headers):
    caliper = client.get('/api/measuringtools/types').get_json()['data']
    caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper')

    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-001',
        'name': 'Bench caliper',
        'serialnumber': 'SN-CAL-9',
        'gaugelabreference': 'GL-42',
        'measuringtooltypeid': caliper_id,
        'statusid': _status_id(client),
        'calibrationintervaldays': 365,
        'lastcalibrationdate': '2026-01-01',
        'nextcalibrationdate': '2027-01-01',
        'calibrationprovider': 'Metro Cal Lab',
    })
    assert created.status_code == 201, created.get_json()
    payload = created.get_json()['data']
    # Asset core fields at the top level, extension nested.
    assert payload['assetnumber'] == 'MT-001'
    assert payload['serialnumber'] == 'SN-CAL-9'
    assert payload['gaugelabreference'] == 'GL-42'
    ext = payload['measuringtool']
    assert ext['measuringtooltypename'] == 'Caliper'
    assert ext['calibrationprovider'] == 'Metro Cal Lab'
    assert ext['calibrationstatus'] == 'current'
    tool_id = ext['measuringtoolid']

    fetched = client.get(f'/api/measuringtools/{tool_id}')
    assert fetched.status_code == 200
    assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id


def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers):
    """A tool at an operation location exposes locationcode (leading token)
    and locationname so a printed label can encode the inspection operation."""
    with mt_app.app_context():
        from shopdb.core.models import Location
        location = Location(locationname='0615 Blisk Inspection')
        _db.session.add(location)
        _db.session.commit()
        location_id = location.locationid

    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client),
        'locationid': location_id,
    })
    assert created.status_code == 201, created.get_json()
    payload = created.get_json()['data']
    assert payload['locationname'] == '0615 Blisk Inspection'
    assert payload['locationcode'] == '0615'


def test_tool_payload_location_code_none_when_unplaced(client, auth_headers):
    """A tool with no location degrades gracefully: locationcode is None."""
    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client),
    })
    assert created.status_code == 201, created.get_json()
    assert created.get_json()['data']['locationcode'] is None


def test_tool_update_merged_payload(client, auth_headers):
    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client),
        'nextcalibrationdate': '2030-01-01',
    })
    tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']

    updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={
        'name': 'Renamed tool',                # asset core field
        'calibrationprovider': 'In-house',     # extension field
        'nextcalibrationdate': str(date.today() - timedelta(days=5)),  # -> overdue
    })
    assert updated.status_code == 200, updated.get_json()
    data = updated.get_json()['data']
    assert data['name'] == 'Renamed tool'
    assert data['measuringtool']['calibrationprovider'] == 'In-house'
    assert data['measuringtool']['calibrationstatus'] == 'overdue'


def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers):
    body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)}
    first = client.post('/api/measuringtools', headers=auth_headers, json=body)
    assert first.status_code == 201
    second = client.post('/api/measuringtools', headers=auth_headers, json=body)
    assert second.status_code == 409


def test_list_filter_by_calibrationstatus(client, auth_headers):
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
        'nextcalibrationdate': str(date.today() - timedelta(days=10)),
    })
    response = client.get('/api/measuringtools?calibrationstatus=overdue')
    assert response.status_code == 200
    rows = response.get_json()['data']
    assert rows, 'expected at least one overdue tool'
    assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows)


# -- Maintenance reference identifier -----------------------------------------

def test_maintenancereference_roundtrip(client, auth_headers):
    """Maintenance reference persists on create and update."""
    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client),
        'maintenancereference': 'MNT-9',
    })
    assert created.status_code == 201, created.get_json()
    assert created.get_json()['data']['maintenancereference'] == 'MNT-9'
    tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']

    updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers,
                         json={'maintenancereference': 'MNT-10'})
    assert updated.status_code == 200
    assert updated.get_json()['data']['maintenancereference'] == 'MNT-10'


# -- Global search ------------------------------------------------------------

def _mt_search_hits(client, auth_headers, term):
    resp = client.get(f'/api/search?q={term}', headers=auth_headers)
    assert resp.status_code == 200, resp.get_json()
    return [r for r in resp.get_json()['data']['results']
            if r.get('type') == 'measuring_tool']


def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch):
    """A measuring tool appears in global search by asset number, routed right."""
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)})
    pm = mt_app.extensions['plugin_manager']
    monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
    hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN')
    assert hits
    assert hits[0]['url'].startswith('/measuringtools/')


def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch):
    """A gage-tag lookup by gaugelabreference finds the tool."""
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client),
        'gaugelabreference': 'GLREF-778'})
    pm = mt_app.extensions['plugin_manager']
    monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
    hits = _mt_search_hits(client, auth_headers, 'GLREF-778')
    assert hits
    assert hits[0]['url'].startswith('/measuringtools/')


def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch):
    """search_measuring_tool_enabled=false hides measuring tools from search."""
    from shopdb.core.models import Setting
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client),
        'gaugelabreference': 'GLREF-OFF-1'})
    pm = mt_app.extensions['plugin_manager']
    monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
    Setting.set('search_measuring_tool_enabled', False, valuetype='boolean',
                category='search')
    try:
        assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == []
        assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == []
    finally:
        Setting.set('search_measuring_tool_enabled', True, valuetype='boolean',
                    category='search')


# -- Report shape -------------------------------------------------------------

def test_calibration_report_shape(client, auth_headers):
    response = client.get('/api/measuringtools/report/calibration')
    assert response.status_code == 200
    data = response.get_json()['data']
    assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
    assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'}
    # Buckets and counts agree.
    for key, rows in data['buckets'].items():
        assert data['counts'][key] == len(rows)
    assert 'statuscolors' in data


# -- Core integration: asset serialization ------------------------------------

def _caliper_id(client):
    types = client.get('/api/measuringtools/types').get_json()['data']
    return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')


def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers):
    """Asset.to_dict resolves the measuringtool extension (typedata + pluginid)."""
    from shopdb.core.models import Asset
    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-TD-1', 'statusid': _status_id(client),
        'measuringtooltypeid': _caliper_id(client),
    })
    assert created.status_code == 201, created.get_json()
    tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
    assetid = created.get_json()['data']['assetid']

    with mt_app.app_context():
        asset = _db.session.get(Asset, assetid)
        result = asset.to_dict(include_type_data=True)
        assert result['pluginid'] == tool_id
        assert result['typedata']['measuringtooltypename'] == 'Caliper'
        assert result['typedata']['measuringtoolid'] == tool_id


# -- Core integration: shop-floor map -----------------------------------------

def test_map_lists_measuringtool_subtypes(client, auth_headers):
    """The map filter dropdown carries a Measuring Tool subtype list with color."""
    response = client.get('/api/assets/map')
    assert response.status_code == 200, response.get_json()
    subtypes = response.get_json()['data']['filters']['subtypes']
    assert 'Measuring Tool' in subtypes
    names = {s['name'] for s in subtypes['Measuring Tool']}
    assert 'Caliper' in names
    assert all('color' in s for s in subtypes['Measuring Tool'])


def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
    """?assettype=measuring_tool&subtype=<id> returns only that subtype's tools."""
    types = client.get('/api/measuringtools/types').get_json()['data']
    caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
    micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer')
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client),
        'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20})
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client),
        'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})

    response = client.get(
        f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
    assert response.status_code == 200, response.get_json()
    numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
    assert 'MT-MAP-CAL' in numbers
    assert 'MT-MAP-MIC' not in numbers


def test_map_item_carries_measuringtool_typedata(client, auth_headers):
    """A mapped tool's item carries the extension typedata for marker coloring."""
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
        'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
    response = client.get('/api/assets/map?assettype=measuring_tool')
    item = next(a for a in response.get_json()['data']['assets']
                if a['assetnumber'] == 'MT-MAP-TD')
    assert item['typedata']['measuringtooltypename'] == 'Caliper'


# -- Core integration: dashboard ----------------------------------------------

def test_dashboard_counts_include_measuringtools(client, auth_headers):
    """Dashboard total and counts include active measuring tools."""
    before = client.get('/api/dashboard').get_json()['data']
    client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)})
    after = client.get('/api/dashboard').get_json()['data']
    assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1
    assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1
    assert after['counts']['total'] == before['counts']['total'] + 1


# -- Map overlay endpoint (ADR-010) -------------------------------------------

def test_map_overlay_shape_and_derivation(client, auth_headers):
    """map-overlay returns per-asset derived calibration status + color."""
    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client),
        'nextcalibrationdate': str(date.today() - timedelta(days=3))})  # overdue
    assetid = created.get_json()['data']['assetid']

    response = client.get('/api/measuringtools/map-overlay')
    assert response.status_code == 200, response.get_json()
    rows = response.get_json()['data']
    # ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools.
    row = next(r for r in rows if r['assetid'] == assetid)
    assert set(row) == {'assetid', 'color', 'label'}
    assert row['label'] == 'Overdue'
    assert row['color'] == STATUS_COLORS['overdue']


def test_map_overlay_excludes_inactive(client, auth_headers):
    """A soft-deleted tool drops out of the overlay."""
    created = client.post('/api/measuringtools', headers=auth_headers, json={
        'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)})
    tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
    assetid = created.get_json()['data']['assetid']
    client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers)

    rows = client.get('/api/measuringtools/map-overlay').get_json()['data']
    assert all(r['assetid'] != assetid for r in rows)


# -- Presentation route token (ADR-010) ---------------------------------------

def test_asset_presentation_route_token():
    """Presentation route links through the by-asset resolver (only {assetid})."""
    from plugins.measuringtools.plugin import MeasuringToolsPlugin
    entries = MeasuringToolsPlugin().get_asset_presentation()
    entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
    assert entry['route'] == '/measuringtools/by-asset/{assetid}'
