import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import dashboard from './dashboard.js';
import database from './database.js';
import logger from './logger.js';
import safe from 'safetydance';
import {
    generateRegistrationOptions,
    verifyRegistrationResponse,
    generateAuthenticationOptions,
    verifyAuthenticationResponse
} from '@simplewebauthn/server';
import _ from './underscore.js';

const { log } = logger('passkeys');

const PASSKEY_FIELDS = [ 'id', 'userId', 'credentialId', 'publicKey', 'counter', 'transports', 'name', 'creationTime', 'lastUsedTime' ].join(',');

// In-memory challenge store with expiration (challenges are short-lived)
const gChallenges = new Map();
const CHALLENGE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes

function removePrivateFields(passkey) {
    return _.pick(passkey, ['id', 'name', 'creationTime', 'lastUsedTime']);
}

function postProcess(passkey) {
    if (!passkey) return null;
    passkey.transports = passkey.transports ? JSON.parse(passkey.transports) : [];
    return passkey;
}

async function listByUserId(userId) {
    assert.strictEqual(typeof userId, 'string');

    const results = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE userId = ? ORDER BY creationTime`, [ userId ]);
    return results.map(postProcess);
}

async function get(id) {
    assert.strictEqual(typeof id, 'string');

    const result = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE id = ?`, [ id ]);
    if (result.length === 0) return null;
    return postProcess(result[0]);
}

async function getByCredentialId(credentialId) {
    assert.strictEqual(typeof credentialId, 'string');

    const result = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE credentialId = ?`, [ credentialId ]);
    if (result.length === 0) return null;
    return postProcess(result[0]);
}

async function add(userId, credentialId, publicKey, counter, transports, name) {
    assert.strictEqual(typeof userId, 'string');
    assert.strictEqual(typeof credentialId, 'string');
    assert.strictEqual(typeof publicKey, 'string');
    assert.strictEqual(typeof counter, 'number');
    assert(Array.isArray(transports));
    assert.strictEqual(typeof name, 'string');

    const id = 'pk-' + crypto.randomUUID();

    const query = 'INSERT INTO passkeys (id, userId, credentialId, publicKey, counter, transports, name) VALUES (?, ?, ?, ?, ?, ?, ?)';
    const args = [ id, userId, credentialId, publicKey, counter, JSON.stringify(transports), name ];

    const [error] = await safe(database.query(query, args));
    if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Passkey already registered');
    if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    if (error) throw error;

    return { id };
}

async function del(id) {
    assert.strictEqual(typeof id, 'string');

    const result = await database.query('DELETE FROM passkeys WHERE id = ?', [ id ]);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
}

// this counter prevents replay attacks
async function updateCounter(id, counter) {
    assert.strictEqual(typeof id, 'string');
    assert.strictEqual(typeof counter, 'number');

    await database.query('UPDATE passkeys SET counter = ?, lastUsedTime = NOW() WHERE id = ?', [ counter, id ]);
}

async function delAll() {
    await database.query('DELETE FROM passkeys');
}

function storeChallenge(userId, challenge) {
    gChallenges.set(userId, {
        challenge,
        expiresAt: Date.now() + CHALLENGE_EXPIRY_MS
    });

    // clean up expired challenges
    for (const [k, v] of gChallenges) {
        if (v.expiresAt < Date.now()) gChallenges.delete(k);
    }
}

function getAndDeleteChallenge(userId) {
    const entry = gChallenges.get(userId);
    gChallenges.delete(userId);

    if (!entry) return null;
    if (entry.expiresAt < Date.now()) return null;

    return entry.challenge;
}

async function getRpInfo() {
    const { fqdn } = await dashboard.getLocation();

    return {
        rpId: fqdn,
        rpName: 'Cloudron',
        origin: `https://${fqdn}`
    };
}

async function getRegistrationOptions(user) {
    assert.strictEqual(typeof user, 'object');

    const existingPasskeys = await listByUserId(user.id);
    if (existingPasskeys.length > 0) throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered');

    const { rpId, rpName } = await getRpInfo();

    const options = await generateRegistrationOptions({
        rpName,
        rpID: rpId,
        userName: user.username || user.email,
        userDisplayName: user.displayName || user.username || user.email,
        userID: new TextEncoder().encode(user.id),
        attestationType: 'none',
        authenticatorSelection: {
            residentKey: 'preferred',
            userVerification: 'preferred'
        }
    });

    storeChallenge(user.id, options.challenge);

    log(`getRegistrationOptions: generated for user ${user.id}`);

    return options;
}

async function verifyRegistration(user, response, name) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof response, 'object');
    assert.strictEqual(typeof name, 'string');

    // check should ideally be in a transaction
    const existingPasskeys = await listByUserId(user.id);
    if (existingPasskeys.length > 0) throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered');

    const expectedChallenge = getAndDeleteChallenge(user.id);
    if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found');

    const { rpId, origin } = await getRpInfo();

    const [error, verification] = await safe(verifyRegistrationResponse({
        response,
        expectedChallenge,
        expectedOrigin: origin,
        expectedRPID: rpId
    }));

    if (error) {
        log(`verifyRegistration: verification failed for user ${user.id}:`, error);
        throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
    }

    if (!verification.verified || !verification.registrationInfo) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');

    const { credential } = verification.registrationInfo;

    // credential.id is already base64url encoded by simplewebauthn
    // credential.publicKey is a Uint8Array, store as base64
    const publicKeyBase64 = Buffer.from(credential.publicKey).toString('base64');

    const result = await add(
        user.id,
        credential.id,  // already base64url
        publicKeyBase64,
        credential.counter,
        credential.transports || [],
        name || 'Passkey'
    );

    log(`verifyRegistration: passkey registered for user ${user.id}`);

    return result;
}

async function getAuthenticationOptions(user) {
    assert.strictEqual(typeof user, 'object');

    const { rpId } = await getRpInfo();

    const existingPasskeys = await listByUserId(user.id);
    if (existingPasskeys.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'No passkeys registered');

    const options = await generateAuthenticationOptions({
        rpID: rpId,
        allowCredentials: existingPasskeys.map(pk => ({
            id: pk.credentialId,
            transports: pk.transports
        })),
        userVerification: 'preferred'
    });

    storeChallenge(user.id, options.challenge);

    log(`getAuthenticationOptions: generated for user ${user.id}`);

    return options;
}

async function verifyAuthentication(user, response) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof response, 'object');

    const expectedChallenge = getAndDeleteChallenge(user.id);
    if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found');

    const { rpId, origin } = await getRpInfo();

    // Find the passkey being used - response.id is base64url encoded (matches our stored format)
    const credentialIdBase64url = response.id;
    const passkey = await getByCredentialId(credentialIdBase64url);

    if (!passkey) {
        log(`verifyAuthentication: passkey not found for credential ${credentialIdBase64url}`);
        throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
    }

    if (passkey.userId !== user.id) {
        log(`verifyAuthentication: passkey belongs to different user`);
        throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey does not belong to this user');
    }

    const publicKey = new Uint8Array(Buffer.from(passkey.publicKey, 'base64'));

    const [error, verification] = await safe(verifyAuthenticationResponse({
        response,
        expectedChallenge,
        expectedOrigin: origin,
        expectedRPID: rpId,
        credential: {
            id: passkey.credentialId,
            publicKey,
            counter: passkey.counter,
            transports: passkey.transports
        }
    }));

    if (error) {
        log(`verifyAuthentication: verification failed for user ${user.id}:`, error);
        throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
    }

    if (!verification.verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');

    await updateCounter(passkey.id, verification.authenticationInfo.newCounter);

    log(`verifyAuthentication: passkey verified for user ${user.id}`);

    return { verified: true, passkeyId: passkey.id };
}

async function getDiscoverableAuthOptions(challengeKey) {
    assert.strictEqual(typeof challengeKey, 'string');

    const { rpId } = await getRpInfo();

    const options = await generateAuthenticationOptions({
        rpID: rpId,
        allowCredentials: [],
        userVerification: 'preferred'
    });

    storeChallenge(challengeKey, options.challenge);

    log(`getDiscoverableAuthOptions: generated for interaction ${challengeKey}`);

    return options;
}

async function verifyDiscoverableAuth(challengeKey, response) {
    assert.strictEqual(typeof challengeKey, 'string');
    assert.strictEqual(typeof response, 'object');

    const expectedChallenge = getAndDeleteChallenge(challengeKey);
    if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found');

    const { rpId, origin } = await getRpInfo();

    const credentialIdBase64url = response.id;
    const passkey = await getByCredentialId(credentialIdBase64url);

    if (!passkey) {
        log(`verifyDiscoverableAuth: passkey not found for credential ${credentialIdBase64url}`);
        throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
    }

    const publicKey = new Uint8Array(Buffer.from(passkey.publicKey, 'base64'));

    const [error, verification] = await safe(verifyAuthenticationResponse({
        response,
        expectedChallenge,
        expectedOrigin: origin,
        expectedRPID: rpId,
        credential: {
            id: passkey.credentialId,
            publicKey,
            counter: passkey.counter,
            transports: passkey.transports
        }
    }));

    if (error) {
        log(`verifyDiscoverableAuth: verification failed:`, error);
        throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
    }

    if (!verification.verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');

    await updateCounter(passkey.id, verification.authenticationInfo.newCounter);

    log(`verifyDiscoverableAuth: passkey verified for user ${passkey.userId}`);

    return { verified: true, passkeyId: passkey.id, userId: passkey.userId };
}

export default {
    listByUserId,
    get,
    getByCredentialId,
    add,
    del,
    // this is only for dashboard origin changes
    delAll,

    getRegistrationOptions,
    verifyRegistration,
    getAuthenticationOptions,
    verifyAuthentication,
    getDiscoverableAuthOptions,
    verifyDiscoverableAuth,

    removePrivateFields
};
