"""Personal API token tests.

Covers: create returns the secret once and stores only a hash; a PAT
authenticates a permission-gated write as its owner; a PAT is rejected when its
owner lacks the permission; expired and revoked tokens are rejected; lastusedat
updates on use; a non-owner member cannot revoke someone else's token; an admin
lists everyone's tokens with ?all=true; and import mode works over a PAT for an
admin.
"""

from datetime import datetime, timedelta, timezone

from werkzeug.security import generate_password_hash

from shopdb.core.models import ApiToken, Application, Vendor
from shopdb.extensions import db as _db


def _naive_utcnow():
    return datetime.now(timezone.utc).replace(tzinfo=None)


def _create_token(client, headers, name='test token', expiresat=None, scopes=None):
    body = {'name': name}
    if expiresat is not None:
        body['expiresat'] = expiresat
    if scopes is not None:
        body['scopes'] = scopes
    response = client.post('/api/apitokens', json=body, headers=headers)
    return response


def _pat_headers(secret):
    return {'Authorization': f'Bearer {secret}'}


def _user_with_perms(client, db, username, permnames):
    """Create a non-admin user holding permnames, return (user, login headers).

    Seeds the catalog so the named Permission rows exist, links them to a fresh
    role, and logs the user in. Used to prove the apitokens.create gate and the
    owner scope ceiling for non-admin owners.
    """
    from shopdb.core.models import User, Role, Permission
    Permission.seed()
    role = Role(rolename=username + 'role')
    db.session.add(role)
    db.session.flush()
    role.permissions = Permission.query.filter(
        Permission.name.in_(permnames)).all()
    user = User(username=username, email=username + '@test.local',
                passwordhash=generate_password_hash('testpass'))
    user.roles.append(role)
    db.session.add(user)
    db.session.commit()
    login = client.post('/api/auth/login',
                        json={'username': username, 'password': 'testpass'})
    headers = {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
    return user, headers


def test_create_returns_secret_once_and_stores_hash(client, db, auth_headers):
    response = _create_token(client, auth_headers, name='import runner')
    assert response.status_code == 201
    data = response.get_json()['data']

    secret = data['secret']
    assert secret.startswith('shopdb_pat_')
    assert 'warning' in data
    # The stored row must not carry the raw secret; only its hash.
    token = ApiToken.query.filter_by(tokenid=data['tokenid']).first()
    assert token is not None
    assert token.tokenhash == ApiToken.hash_secret(secret)
    assert secret not in (token.tokenhash, token.tokenprefix or '')
    assert token.tokenprefix and token.tokenprefix in secret


def test_pat_authenticates_permission_write_as_owner(client, db, admin_user,
                                                     auth_headers):
    """A PAT owned by an admin can create a vendor (admin-gated write)."""
    secret = _create_token(client, auth_headers).get_json()['data']['secret']

    response = client.post('/api/vendors', json={'vendor': 'PAT Vendor'},
                           headers=_pat_headers(secret))
    assert response.status_code == 201
    assert Vendor.query.filter_by(vendor='PAT Vendor').first() is not None


def test_pat_403_when_owner_lacks_permission(client, db):
    """A PAT owned by a non-admin (who can mint tokens but is not admin) is
    forbidden from an admin-role write."""
    _user, headers = _user_with_perms(client, db, 'tokenmaker',
                                      ['apitokens.create'])
    secret = _create_token(client, headers).get_json()['data']['secret']

    response = client.post('/api/vendors', json={'vendor': 'Nope'},
                           headers=_pat_headers(secret))
    assert response.status_code == 403
    assert Vendor.query.filter_by(vendor='Nope').first() is None


def test_expired_token_rejected(client, db, admin_user, auth_headers):
    secret = _create_token(client, auth_headers).get_json()['data']['secret']
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    token.expiresat = _naive_utcnow() - timedelta(days=1)
    _db.session.commit()

    response = client.post('/api/vendors', json={'vendor': 'Expired'},
                           headers=_pat_headers(secret))
    assert response.status_code == 401
    assert Vendor.query.filter_by(vendor='Expired').first() is None


def test_revoked_token_rejected(client, db, admin_user, auth_headers):
    secret = _create_token(client, auth_headers).get_json()['data']['secret']
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    tokenid = token.tokenid

    revoke = client.delete(f'/api/apitokens/{tokenid}', headers=auth_headers)
    assert revoke.status_code == 200

    response = client.post('/api/vendors', json={'vendor': 'Revoked'},
                           headers=_pat_headers(secret))
    assert response.status_code == 401
    assert Vendor.query.filter_by(vendor='Revoked').first() is None


def test_lastusedat_updates_on_use(client, db, admin_user, auth_headers):
    secret = _create_token(client, auth_headers).get_json()['data']['secret']
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    assert token.lastusedat is None

    client.get('/api/apitokens', headers=_pat_headers(secret))
    _db.session.expire_all()
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    assert token.lastusedat is not None


def test_member_cannot_revoke_other_users_token(client, db, admin_user,
                                                auth_headers):
    """A token-capable non-admin cannot revoke a token owned by someone else."""
    secret = _create_token(client, auth_headers).get_json()['data']['secret']
    tokenid = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first().tokenid

    _user, headers = _user_with_perms(client, db, 'tokenmaker',
                                      ['apitokens.create'])
    response = client.delete(f'/api/apitokens/{tokenid}', headers=headers)
    assert response.status_code == 403
    # Still active.
    assert _db.session.get(ApiToken, tokenid).isactive is True


def test_roleless_member_cannot_create_token(client, db, member_user,
                                             member_headers):
    """Creating a token now requires apitokens.create; a role-less member is
    denied with 403."""
    response = _create_token(client, member_headers, name='nope')
    assert response.status_code == 403


def test_member_with_permission_can_manage_own_token(client, db):
    """A non-admin granted apitokens.create manages their OWN tokens."""
    _user, headers = _user_with_perms(client, db, 'tokenmaker',
                                      ['apitokens.create'])
    create = _create_token(client, headers, name='mine')
    assert create.status_code == 201
    tokenid = create.get_json()['data']['tokenid']

    revoke = client.delete(f'/api/apitokens/{tokenid}', headers=headers)
    assert revoke.status_code == 200
    assert _db.session.get(ApiToken, tokenid).isactive is False


def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers):
    _create_token(client, auth_headers, name='admin token')
    member, member_headers = _user_with_perms(client, db, 'tokenmaker',
                                              ['apitokens.create'])
    _create_token(client, member_headers, name='member token')

    # Own-only (default) for admin: just the admin's token.
    own = client.get('/api/apitokens', headers=auth_headers).get_json()['data']
    assert all(t['userid'] == admin_user.userid for t in own)

    # all=true: both users' tokens, with owner usernames.
    everyone = client.get('/api/apitokens?all=true',
                          headers=auth_headers).get_json()['data']
    userids = {t['userid'] for t in everyone}
    assert admin_user.userid in userids and member.userid in userids
    assert any(t.get('username') for t in everyone)


def test_member_all_true_ignored(client, db, admin_user, auth_headers):
    """A non-admin passing ?all=true still only sees their own tokens."""
    _create_token(client, auth_headers, name='admin token')
    member, member_headers = _user_with_perms(client, db, 'tokenmaker',
                                              ['apitokens.create'])
    _create_token(client, member_headers, name='member token')

    result = client.get('/api/apitokens?all=true',
                        headers=member_headers).get_json()['data']
    assert all(t['userid'] == member.userid for t in result)


def test_import_mode_works_over_pat(client, db, admin_user, auth_headers):
    """An admin PAT plus X-Import-Mode backdates createddate on a write."""
    secret = _create_token(client, auth_headers).get_json()['data']['secret']

    headers = _pat_headers(secret)
    headers['X-Import-Mode'] = 'true'
    response = client.post(
        '/api/vendors',
        json={'vendor': 'Legacy Vendor', 'createddate': '2019-01-02 03:04:05'},
        headers=headers)
    assert response.status_code == 201

    vendor = Vendor.query.filter_by(vendor='Legacy Vendor').first()
    assert vendor is not None
    assert vendor.createddate == datetime(2019, 1, 2, 3, 4, 5)


# --- Scoped tokens -------------------------------------------------------

def test_scoped_token_passes_listed_permission(client, db, admin_user,
                                               auth_headers):
    """A token scoped to applications.create can create an application."""
    secret = _create_token(client, auth_headers,
                           scopes=['applications.create']).get_json()['data']['secret']

    response = client.post('/api/applications', json={'appname': 'Scoped App'},
                           headers=_pat_headers(secret))
    assert response.status_code == 201
    assert Application.query.filter_by(appname='Scoped App').first() is not None


def test_scoped_token_403_on_unlisted_permission(client, db, admin_user,
                                                 auth_headers):
    """A token scoped to only applications.create cannot delete (needs
    applications.delete, which is not in scope)."""
    app = Application(appname='Target App')
    _db.session.add(app)
    _db.session.commit()
    appid = app.appid

    secret = _create_token(client, auth_headers,
                           scopes=['applications.create']).get_json()['data']['secret']
    response = client.delete(f'/api/applications/{appid}',
                             headers=_pat_headers(secret))
    assert response.status_code == 403
    assert _db.session.get(Application, appid) is not None


def test_scoped_admin_token_does_not_bypass(client, db, admin_user, auth_headers):
    """An admin owner's scoped token is genuinely limited: the admin permission
    bypass is suspended, so an unlisted action is 403 even for an admin."""
    secret = _create_token(client, auth_headers,
                           scopes=['applications.create']).get_json()['data']['secret']

    # settings.edit is not scoped -> 403 despite the owner being admin.
    response = client.put('/api/settings/site_name',
                          json={'value': 'Hacked'}, headers=_pat_headers(secret))
    assert response.status_code == 403


def test_scoped_token_denied_on_require_role_route(client, db, admin_user,
                                                  auth_headers):
    """Scoped tokens cannot pass a role gate. Vendor writes are require_role
    admin, so even an admin-owned scoped token is denied."""
    secret = _create_token(client, auth_headers,
                           scopes=['applications.create']).get_json()['data']['secret']

    response = client.post('/api/vendors', json={'vendor': 'RoleGated'},
                           headers=_pat_headers(secret))
    assert response.status_code == 403
    assert Vendor.query.filter_by(vendor='RoleGated').first() is None


def test_unscoped_token_unchanged(client, db, admin_user, auth_headers):
    """Regression: an unscoped admin token still acts with full authority,
    including role-gated writes."""
    secret = _create_token(client, auth_headers).get_json()['data']['secret']

    response = client.post('/api/vendors', json={'vendor': 'Unscoped Vendor'},
                           headers=_pat_headers(secret))
    assert response.status_code == 201
    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret)).first()
    assert token.scopelist is None


def test_unknown_scope_name_rejected_at_create(client, db, admin_user,
                                               auth_headers):
    """An unknown permission name in scopes is rejected 400, listing it."""
    response = _create_token(client, auth_headers, scopes=['not.a.permission'])
    assert response.status_code == 400
    assert 'not.a.permission' in response.get_json()['data']['error']['message']


def test_scope_ceiling_rejects_permission_owner_lacks(client, db):
    """The scope ceiling: a non-admin cannot mint a token scoped to a
    permission they do not hold."""
    _user, headers = _user_with_perms(client, db, 'tokenmaker',
                                      ['apitokens.create'])
    response = _create_token(client, headers, scopes=['applications.create'])
    assert response.status_code == 400
    assert 'applications.create' in response.get_json()['data']['error']['message']


def test_non_admin_scoped_token_capped_at_own_permissions(client, db):
    """A non-admin holding applications.create + apitokens.create may mint a
    token scoped to applications.create and use it."""
    _user, headers = _user_with_perms(
        client, db, 'tokenmaker', ['apitokens.create', 'applications.create'])
    secret = _create_token(client, headers,
                           scopes=['applications.create']).get_json()['data']['secret']

    response = client.post('/api/applications', json={'appname': 'Capped App'},
                           headers=_pat_headers(secret))
    assert response.status_code == 201


def test_scoped_token_intersects_at_use_time(client, db):
    """Defense in depth: if the owner loses a scoped permission after the token
    is minted, the token can no longer use it (use-time intersection)."""
    from shopdb.core.models import Role
    _user, headers = _user_with_perms(
        client, db, 'tokenmaker', ['apitokens.create', 'applications.create'])
    secret = _create_token(client, headers,
                           scopes=['applications.create']).get_json()['data']['secret']

    # Strip applications.create from the owner's role.
    role = Role.query.filter_by(rolename='tokenmakerrole').first()
    role.permissions = [p for p in role.permissions
                        if p.name != 'applications.create']
    _db.session.commit()

    response = client.post('/api/applications', json={'appname': 'Gone'},
                           headers=_pat_headers(secret))
    assert response.status_code == 403
    assert Application.query.filter_by(appname='Gone').first() is None


def test_import_mode_denied_over_scoped_admin_token(client, db, admin_user,
                                                   auth_headers):
    """A scoped admin token does NOT get import mode (an admin-role capability):
    the createddate in the body is ignored and 'now' is stamped instead."""
    secret = _create_token(
        client, auth_headers,
        scopes=['applications.create']).get_json()['data']['secret']

    headers = _pat_headers(secret)
    headers['X-Import-Mode'] = 'true'
    before = _naive_utcnow()
    response = client.post(
        '/api/applications',
        json={'appname': 'No Backdate', 'createddate': '2019-01-02 03:04:05'},
        headers=headers)
    assert response.status_code == 201

    app = Application.query.filter_by(appname='No Backdate').first()
    assert app is not None
    # Import mode was denied, so createddate is ~now, not the 2019 value.
    assert app.createddate >= before


def test_scopes_update_round_trip(client, db, admin_user, auth_headers):
    """Scopes can be set, changed, and cleared via PUT, and surface in GET."""
    create = _create_token(client, auth_headers, scopes=['applications.create'])
    tokenid = create.get_json()['data']['tokenid']

    got = client.get('/api/apitokens', headers=auth_headers).get_json()['data']
    row = next(t for t in got if t['tokenid'] == tokenid)
    assert row['scopes'] == ['applications.create']

    # Change scopes.
    updated = client.put(f'/api/apitokens/{tokenid}',
                         json={'scopes': ['applications.edit']},
                         headers=auth_headers)
    assert updated.status_code == 200
    assert updated.get_json()['data']['scopes'] == ['applications.edit']

    # Clear scopes (back to unscoped/full).
    cleared = client.put(f'/api/apitokens/{tokenid}', json={'scopes': None},
                         headers=auth_headers)
    assert cleared.status_code == 200
    assert cleared.get_json()['data']['scopes'] is None


def test_collector_scoped_token_denied_on_normal_endpoints(client, db,
                                                           admin_user,
                                                           auth_headers):
    """A collector-scoped token (collector.ingest only) is contained: the shim
    still swaps it into a JWT, but collector.ingest gates no normal route, so it
    is denied on a permission-gated create just like any other unlisted scope."""
    secret = _create_token(
        client, auth_headers,
        scopes=['collector.ingest']).get_json()['data']['secret']

    response = client.post('/api/applications', json={'appname': 'Nope'},
                           headers=_pat_headers(secret))
    assert response.status_code == 403
    assert Application.query.filter_by(appname='Nope').first() is None


def test_update_scopes_ceiling_enforced(client, db):
    """PUT scopes is also capped at the owner's permissions."""
    _user, headers = _user_with_perms(client, db, 'tokenmaker',
                                      ['apitokens.create'])
    tokenid = _create_token(client, headers).get_json()['data']['tokenid']

    response = client.put(f'/api/apitokens/{tokenid}',
                          json={'scopes': ['applications.delete']},
                          headers=headers)
    assert response.status_code == 400
    assert 'applications.delete' in response.get_json()['data']['error']['message']
