"""Authorization gating and login-lockout tests.

Covers the security fixes:
  - write routes require a permission/role, not just authentication
  - the admin role bypasses permission checks
  - repeated bad logins lock the account

The parametrized sweep at the bottom is the standing CI guard: it walks the
whole URL map and proves every mutating route (POST/PUT/PATCH/DELETE) either
rejects a role-less member with 403 or is on the deliberate exemption list.
A future unguarded mutation therefore fails CI the moment it is added.
"""

import re

import pytest

from shopdb import create_app


# HTTP verbs that change state. GETs are intentionally left alone (kiosk pages
# rely on optional-jwt reads).
MUTATING_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}

# Blueprints exempt by design (see task/ADR-006):
#   auth      - login/refresh/logout are the auth surface itself
#   collector - agent ingest, authenticated by per-plugin API key (ADR-006)
#   setup     - first-run wizard, self-gated by the needs-admin state
EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}

# Individual endpoints exempt by design.
#   knowledgebase.track_click - kiosk click-through counter. Optional-jwt
#     telemetry: increments a click count on an already-active article and
#     returns its link URL. Takes no caller-supplied data; gating it would
#     break the unauthenticated kiosk redirect it exists to serve.
#   users.update_user - admin-or-self (inline check, not a flat deny): a
#     role-less member MAY edit their own record, so it does not fit the
#     403-for-every-member contract this sweep asserts. The other-user 403 is
#     covered by test_member_cannot_update_other_user below.
# The apitokens create/update/revoke endpoints are NOT exempt: they now require
# the apitokens.create permission, so a role-less member gets the 403 this sweep
# asserts (ownership is still enforced inside the handler for non-admins).
#   geenforce.post_report - GE-Enforce client ingest, authenticated by a
#     geenforce.report managed service token (X-API-Key/Bearer), not JWT. Same
#     shape as the exempt collector blueprint; the geenforce admin endpoints in
#     the same blueprint are JWT+permission gated and ARE swept.
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
                    'geenforce.post_report',
                    # Deliberately open kiosk write: decrement-only,
                    # badge-attributed server-side. Decision record in
                    # docs/proposals/printedparts-plugin.md; justification in
                    # the plugin README.
                    'printedparts.kiosk_take'}


@pytest.fixture(autouse=True)
def _selfhosted_directory_modes(db):
    # The usb/employees plugins default to EXTERNAL directory mode when no
    # setting row exists, so exercising their handlers reaches for a MySQL
    # server that exists on the dev box but not in CI. Pin selfhosted mode
    # so the sweep is deterministic everywhere.
    from shopdb.core.models import Setting
    db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
                           valuetype='string', category='site'))
    db.session.add(Setting(key='employee_directory_mode', value='selfhosted',
                           valuetype='string', category='site'))
    db.session.commit()


def _fill_url(rule):
    """Turn a Werkzeug rule into a concrete path (int/float params -> 1, else x)."""
    def repl(match):
        converter = match.group(1) or ''
        return '1' if converter.startswith(('int', 'float')) else 'x'
    # matches <name>, <int:name>, <path:name>, ...
    return re.sub(r'<(?:([^:>]+):)?[^>]+>', repl, rule)


def _mutating_routes():
    """Every non-exempt mutating (endpoint, method, url) in the URL map.

    Enumerated from a throwaway app: route strings are stable across app
    instances, so the requests below run against the fixture client's app.
    """
    app = create_app('testing')
    routes = []
    for rule in app.url_map.iter_rules():
        methods = (rule.methods or set()) & MUTATING_METHODS
        if not methods:
            continue
        blueprint = rule.endpoint.split('.')[0]
        if blueprint in EXEMPT_BLUEPRINTS or rule.endpoint in EXEMPT_ENDPOINTS:
            continue
        for method in sorted(methods):
            routes.append((rule.endpoint, method, _fill_url(rule.rule)))
    return routes


# Collected once at import so each route is its own parametrized case (a
# newly-unguarded mutation shows up as a single named CI failure).
MUTATING_ROUTES = _mutating_routes()
ROUTE_IDS = [f'{ep}[{method}]' for ep, method, _ in MUTATING_ROUTES]


def test_member_cannot_write_settings(client, db, member_headers):
    """An authenticated user with no roles is forbidden from editing settings."""
    from shopdb.core.models import Setting

    setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean',
                      category='integrations')
    db.session.add(setting)
    db.session.commit()

    response = client.put('/api/settings/zabbix_enabled',
                          json={'value': True}, headers=member_headers)
    assert response.status_code == 403
    assert response.get_json()['data']['error']['code'] == 'FORBIDDEN'


def test_admin_can_write_settings(client, db, auth_headers):
    """The admin role bypasses the permission check and may edit settings."""
    from shopdb.core.models import Setting

    setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean',
                      category='integrations')
    db.session.add(setting)
    db.session.commit()

    response = client.put('/api/settings/zabbix_enabled',
                          json={'value': True}, headers=auth_headers)
    assert response.status_code == 200


def test_member_cannot_delete_asset(client, db, member_headers):
    """A role-less user cannot delete assets (was previously allowed)."""
    response = client.delete('/api/assets/1', headers=member_headers)
    # Forbidden by authz, not a 404 - the gate runs before the lookup.
    assert response.status_code == 403


def test_unauthenticated_write_is_rejected(client, db):
    """No token at all still cannot reach a write route."""
    response = client.delete('/api/assets/1')
    assert response.status_code in (401, 422)  # missing/!invalid JWT


def test_member_cannot_create_business_unit(client, db, member_headers):
    """Reference-data writes are admin-only."""
    response = client.post('/api/businessunits',
                           json={'businessunit': 'Test BU'}, headers=member_headers)
    assert response.status_code == 403


def test_member_cannot_list_users(client, db, member_headers):
    """User listing is admin-only (converted from inline check to decorator)."""
    response = client.get('/api/users', headers=member_headers)
    assert response.status_code == 403
    assert response.get_json()['data']['error']['code'] == 'FORBIDDEN'


def test_member_cannot_create_user(client, db, member_headers):
    """User creation is admin-only."""
    response = client.post('/api/users',
                           json={'username': 'x', 'email': 'x@test.local',
                                 'password': 'secret'},
                           headers=member_headers)
    assert response.status_code == 403


def test_member_cannot_delete_role(client, db, member_headers):
    """Role deletion is admin-only."""
    response = client.delete('/api/users/roles/1', headers=member_headers)
    assert response.status_code == 403


def test_admin_can_list_users(client, db, auth_headers):
    """The admin role passes the require_role gate on user listing."""
    response = client.get('/api/users', headers=auth_headers)
    assert response.status_code == 200


def test_member_can_read_own_user(client, db, member_user, member_headers):
    """Admin-or-self: a role-less user may read their OWN record."""
    response = client.get(f'/api/users/{member_user.userid}',
                          headers=member_headers)
    assert response.status_code == 200
    assert response.get_json()['data']['username'] == 'testmember'


def test_member_cannot_read_other_user(client, db, member_user, member_headers):
    """Admin-or-self: a role-less user may not read a DIFFERENT record."""
    response = client.get(f'/api/users/{member_user.userid + 999}',
                          headers=member_headers)
    assert response.status_code == 403


def test_account_locks_after_repeated_bad_logins(client, db, admin_user):
    """Five bad passwords lock the account; a correct password is then refused."""
    for _ in range(5):
        bad = client.post('/api/auth/login',
                          json={'username': 'testadmin', 'password': 'wrong'})
        assert bad.status_code == 401

    # Account is now locked - even the correct password is refused with 403.
    locked = client.post('/api/auth/login',
                         json={'username': 'testadmin', 'password': 'testpass'})
    assert locked.status_code == 403
    assert 'locked' in locked.get_json()['data']['error']['message'].lower()


def test_lockout_auto_unlocks_after_expiry(client, db, admin_user):
    """Once lockeduntil is in the past the account unlocks itself: a correct
    password logs in and clears the lock state."""
    from datetime import datetime, timezone, timedelta
    # Simulate an expired lock (naive-UTC to match the column comparisons).
    admin_user.lockeduntil = (datetime.now(timezone.utc).replace(tzinfo=None)
                              - timedelta(minutes=1))
    admin_user.failedlogins = 5
    db.session.commit()

    ok = client.post('/api/auth/login',
                     json={'username': 'testadmin', 'password': 'testpass'})
    assert ok.status_code == 200, ok.get_json()

    db.session.refresh(admin_user)
    assert admin_user.lockeduntil is None
    assert admin_user.failedlogins == 0


def test_successful_login_resets_failed_counter(client, db, admin_user):
    """A good login before the threshold clears the failure count."""
    for _ in range(3):
        client.post('/api/auth/login',
                    json={'username': 'testadmin', 'password': 'wrong'})

    ok = client.post('/api/auth/login',
                     json={'username': 'testadmin', 'password': 'testpass'})
    assert ok.status_code == 200

    from shopdb.core.models import User
    user = User.query.filter_by(username='testadmin').first()
    assert user.failedlogins == 0
    assert user.lockeduntil is None


# ---------------------------------------------------------------------------
# Standing CI guard: sweep every mutating route.
# ---------------------------------------------------------------------------

def test_member_cannot_update_other_user(client, db, member_user, member_headers):
    """Admin-or-self write gate: a role-less user cannot edit a DIFFERENT user."""
    response = client.put(f'/api/users/{member_user.userid + 999}',
                          json={'firstname': 'x'}, headers=member_headers)
    assert response.status_code == 403


def test_route_map_has_mutating_routes():
    """Sanity: the sweep actually found routes (catches a broken collector)."""
    assert len(MUTATING_ROUTES) > 50, 'route enumeration looks empty/broken'


@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS)
def test_mutation_rejects_roleless_member(client, db, member_headers,
                                          endpoint, method, url):
    """Every non-exempt mutating route must 403 a logged-in, role-less user.

    A 200/201/400/404/409 here means the route ran its handler for an
    unprivileged caller - i.e. it is not permission-gated. That is the exact
    regression this guard exists to catch.
    """
    response = client.open(url, method=method, headers=member_headers, json={})
    assert response.status_code == 403, (
        f'{method} {endpoint} ({url}) returned {response.status_code}, '
        f'expected 403 - route is not permission-gated. If it is intended to '
        f'be public/kiosk, add it to EXEMPT_ENDPOINTS with a reason.'
    )
    assert response.get_json()['data']['error']['code'] == 'FORBIDDEN'


@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS)
def test_mutation_admin_passes_authz(client, db, auth_headers,
                                     endpoint, method, url):
    """Admin must clear the gate on every mutating route (never 401/403).

    Downstream the handler may 400/404/409/422 on the empty body - that is
    validation, not authorization, and is fine. We only assert the authz gate
    let the admin through.
    """
    response = client.open(url, method=method, headers=auth_headers, json={})
    assert response.status_code not in (401, 403), (
        f'{method} {endpoint} ({url}) returned {response.status_code} for '
        f'admin - the admin role should bypass every permission check.'
    )


# --- Settings exposure allowlist (unauthenticated recon guard) ---------------

def _seed_settings(db):
    from shopdb.core.models import Setting
    db.session.add_all([
        Setting(key='facility_name', value='WJ', valuetype='string', category='site'),
        Setting(key='brand_primary_color', value='#123', valuetype='string', category='branding'),
        Setting(key='smtp_host', value='mail.internal', valuetype='string', category='communication'),
        Setting(key='employee_db_host', value='hr.internal', valuetype='string', category='integrations'),
        Setting(key='zabbix_token', value='supersecret', valuetype='string', category='integrations'),
    ])
    db.session.commit()


def test_unauthenticated_settings_list_is_allowlisted(client, db):
    """No token: only the public allowlist (branding + bootstrap keys) is
    visible; internal infra config is not enumerable."""
    _seed_settings(db)
    keys = {s['key'] for s in client.get('/api/settings').get_json()['data']}
    assert 'facility_name' in keys and 'brand_primary_color' in keys
    assert 'smtp_host' not in keys
    assert 'employee_db_host' not in keys
    assert 'zabbix_token' not in keys


def test_authenticated_settings_list_full_but_masked(client, db, member_headers):
    """Any authed principal sees the full table; secret values stay masked."""
    _seed_settings(db)
    rows = {s['key']: s['value']
            for s in client.get('/api/settings', headers=member_headers).get_json()['data']}
    assert 'smtp_host' in rows and 'employee_db_host' in rows
    assert rows['zabbix_token'] == '********'  # secret masked even when visible


def test_unauthenticated_private_setting_get_is_404(client, db):
    """A single private key is not confirmable without auth; a public one is."""
    _seed_settings(db)
    assert client.get('/api/settings/smtp_host').status_code == 404
    assert client.get('/api/settings/facility_name').status_code == 200


# --- audit.view gating -------------------------------------------------------

def test_member_cannot_read_auditlogs(client, db, member_headers):
    """Audit-read routes now require audit.view; a role-less member is 403."""
    for url in ('/api/auditlogs', '/api/auditlogs/entity/Setting/1', '/api/auditlogs/stats'):
        assert client.get(url, headers=member_headers).status_code == 403, url


def test_admin_can_read_auditlogs(client, db, auth_headers):
    """Admin holds audit.view and reads the audit trail."""
    assert client.get('/api/auditlogs', headers=auth_headers).status_code == 200
