import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
    buildingKey,
    jobsForApp,
    getBotToken,
    isBotEnabled,
    getBotConfig,
    isAuthorized,
    isAdmin,
    getOAuthRedirectUri,
    buildAuthUrl,
    cleanCompletedJobs,
    msToTime,
    COMPLETED_RETENTION_DAYS,
} from '../lib/helpers.js';

// A minimal, self-contained config/tokens pair used across the auth tests so
// each test doesn't have to reconstruct one. Keeping novi enabled + techupdates
// disabled + noman unknown covers the three interesting bot states.
function makeState() {
    return {
        config: {
            webex: {
                bot: {
                    novi: {
                        label: 'Novi Communicator',
                        authorized: {
                            'personA': { id: 'personA' },
                            'personB': { id: 'personB' },
                        },
                    },
                    techupdates: {
                        label: 'Tech Updates',
                        authorized: {
                            'personA': { id: 'personA' },
                        },
                    },
                    orphan: {
                        label: 'Configured but no authorized users',
                    },
                },
            },
        },
        botTokens: {
            novi:        { token: 'novi-token',        enabled: true  },
            techupdates: { token: 'techupdates-token', enabled: false },
            orphan:      { token: 'orphan-token',      enabled: true  },
            flatstring:  'flat-token',
        },
    };
}

describe('buildingKey', () => {
    test('composites cookieId and appName with a colon', () => {
        assert.equal(buildingKey('abc123', 'novi'), 'abc123:novi');
    });

    test('yields distinct keys for the same cookie across different bots', () => {
        assert.notEqual(buildingKey('abc123', 'novi'), buildingKey('abc123', 'techupdates'));
    });

    test('stringifies an undefined cookieId (matches legacy behavior)', () => {
        assert.equal(buildingKey(undefined, 'novi'), 'undefined:novi');
    });
});

describe('jobsForApp', () => {
    const jobs = [
        { jobId: 1, appName: 'novi' },
        { jobId: 2, appName: 'techupdates' },
        { jobId: 3, appName: 'novi' },
        { jobId: 4 },
        null,
    ];

    test('returns only jobs matching the given appName', () => {
        const result = jobsForApp(jobs, 'novi');
        assert.deepEqual(result.map(j => j.jobId), [1, 3]);
    });

    test('returns empty array when appName has no jobs', () => {
        assert.deepEqual(jobsForApp(jobs, 'nomatch'), []);
    });

    test('null / undefined input returns []', () => {
        assert.deepEqual(jobsForApp(null, 'novi'), []);
        assert.deepEqual(jobsForApp(undefined, 'novi'), []);
    });

    test('drops null entries and jobs missing appName', () => {
        assert.equal(jobsForApp(jobs, 'novi').every(j => j && j.appName === 'novi'), true);
    });
});

describe('getBotToken', () => {
    const { botTokens } = makeState();

    test('returns the token for an enabled bot', () => {
        assert.equal(getBotToken(botTokens, 'novi'), 'novi-token');
    });

    test('returns null for an explicitly disabled bot', () => {
        assert.equal(getBotToken(botTokens, 'techupdates'), null);
    });

    test('returns null for an unknown bot', () => {
        assert.equal(getBotToken(botTokens, 'nomatch'), null);
    });

    test('tolerates the flat "appName -> tokenString" shape', () => {
        assert.equal(getBotToken(botTokens, 'flatstring'), 'flat-token');
    });

    test('returns null when botTokens is null or missing', () => {
        assert.equal(getBotToken(null, 'novi'), null);
        assert.equal(getBotToken(undefined, 'novi'), null);
    });

    test('returns null when the entry has no token field', () => {
        assert.equal(getBotToken({ novi: { enabled: true } }, 'novi'), null);
    });
});

describe('isBotEnabled', () => {
    const { botTokens } = makeState();

    test('true for enabled bot', () => {
        assert.equal(isBotEnabled(botTokens, 'novi'), true);
    });

    test('false for disabled bot', () => {
        assert.equal(isBotEnabled(botTokens, 'techupdates'), false);
    });

    test('false for unknown bot', () => {
        assert.equal(isBotEnabled(botTokens, 'nomatch'), false);
    });
});

describe('getBotConfig', () => {
    const { config, botTokens } = makeState();

    test('returns the bot config block when the bot is defined and enabled', () => {
        const cfg = getBotConfig(config, botTokens, 'novi');
        assert.equal(cfg.label, 'Novi Communicator');
    });

    test('returns null when the bot is disabled even if it is in config.json', () => {
        assert.equal(getBotConfig(config, botTokens, 'techupdates'), null);
    });

    test('returns null when the bot is unknown', () => {
        assert.equal(getBotConfig(config, botTokens, 'nomatch'), null);
    });

    test('returns null when appName is falsy', () => {
        assert.equal(getBotConfig(config, botTokens, undefined), null);
        assert.equal(getBotConfig(config, botTokens, ''), null);
    });

    test('returns null when the bot has a token but is not in config.json', () => {
        // flatstring has a token entry but no matching config.webex.bot entry.
        assert.equal(getBotConfig(config, botTokens, 'flatstring'), null);
    });
});

describe('isAuthorized', () => {
    const { config, botTokens } = makeState();

    test('true when the person is listed under the bot and the bot is enabled', () => {
        assert.equal(isAuthorized(config, botTokens, 'novi', 'personA'), true);
    });

    test('false when the person is not listed under this bot', () => {
        assert.equal(isAuthorized(config, botTokens, 'novi', 'stranger'), false);
    });

    test('false when the bot is disabled, even for a listed person', () => {
        assert.equal(isAuthorized(config, botTokens, 'techupdates', 'personA'), false);
    });

    test('false when personId is falsy', () => {
        assert.equal(isAuthorized(config, botTokens, 'novi', undefined), false);
        assert.equal(isAuthorized(config, botTokens, 'novi', ''), false);
    });

    test('false when the bot config has no authorized block', () => {
        assert.equal(isAuthorized(config, botTokens, 'orphan', 'personA'), false);
    });
});

describe('isAdmin', () => {
    test('true when the person appears in config.admins', () => {
        assert.equal(isAdmin({ admins: ['personA', 'personB'] }, 'personA'), true);
    });

    test('false when the person is not in config.admins', () => {
        assert.equal(isAdmin({ admins: ['personA'] }, 'stranger'), false);
    });

    test('false when personId is missing', () => {
        assert.equal(isAdmin({ admins: ['personA'] }, undefined), false);
        assert.equal(isAdmin({ admins: ['personA'] }, ''), false);
    });

    test('false (fails closed) when admins is missing or not an array', () => {
        assert.equal(isAdmin({}, 'personA'), false);
        assert.equal(isAdmin({ admins: null }, 'personA'), false);
        assert.equal(isAdmin({ admins: 'personA' }, 'personA'), false);
    });

    test('false on missing config entirely', () => {
        assert.equal(isAdmin(undefined, 'personA'), false);
        assert.equal(isAdmin(null, 'personA'), false);
    });
});

describe('getOAuthRedirectUri', () => {
    test('replaces the :app placeholder with the appName', () => {
        assert.equal(
            getOAuthRedirectUri('https://bot.example.com/CollabCentral/:app/oauth', 'novi'),
            'https://bot.example.com/CollabCentral/novi/oauth',
        );
    });

    test('returns empty string when the template is missing', () => {
        assert.equal(getOAuthRedirectUri(undefined, 'novi'), '');
        assert.equal(getOAuthRedirectUri('', 'novi'), '');
    });

    test('leaves the template unchanged when :app is absent', () => {
        assert.equal(
            getOAuthRedirectUri('https://bot.example.com/callback', 'novi'),
            'https://bot.example.com/callback',
        );
    });
});

describe('buildAuthUrl', () => {
    const validInputs = {
        clientId: 'test-client-id',
        template: 'https://bot.example.com/CollabCentral/:app/oauth',
        appName: 'novi',
    };

    test('returns a well-formed Webex authorize URL when both env inputs are present', () => {
        const url = new URL(buildAuthUrl(validInputs));
        assert.equal(url.origin + url.pathname, 'https://webexapis.com/v1/authorize');
        assert.equal(url.searchParams.get('client_id'), 'test-client-id');
        assert.equal(url.searchParams.get('response_type'), 'code');
        assert.equal(
            url.searchParams.get('redirect_uri'),
            'https://bot.example.com/CollabCentral/novi/oauth',
        );
        assert.equal(url.searchParams.get('scope'), 'spark:kms spark:people_read');
    });

    test('substitutes appName into the redirect_uri per bot', () => {
        const noviUrl = new URL(buildAuthUrl(validInputs));
        const techUrl = new URL(buildAuthUrl({ ...validInputs, appName: 'techupdates' }));
        assert.notEqual(
            noviUrl.searchParams.get('redirect_uri'),
            techUrl.searchParams.get('redirect_uri'),
        );
        assert.ok(techUrl.searchParams.get('redirect_uri').endsWith('/techupdates/oauth'));
    });

    test('returns null when clientId is missing', () => {
        assert.equal(buildAuthUrl({ ...validInputs, clientId: undefined }), null);
        assert.equal(buildAuthUrl({ ...validInputs, clientId: '' }), null);
    });

    test('returns null when template is missing', () => {
        assert.equal(buildAuthUrl({ ...validInputs, template: undefined }), null);
        assert.equal(buildAuthUrl({ ...validInputs, template: '' }), null);
    });
});

describe('cleanCompletedJobs', () => {
    // Pin "now" to a fixed instant so the age arithmetic is deterministic.
    const NOW = new Date('2026-07-01T12:00:00Z').getTime();
    const daysAgoIso = (n) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString();

    test('drops jobs older than the retention window', () => {
        const jobs = {
            completed: [
                { jobId: 'old',    endTime: daysAgoIso(45) },
                { jobId: 'edge',   endTime: daysAgoIso(31) },
                { jobId: 'recent', endTime: daysAgoIso(10) },
            ],
        };
        const result = cleanCompletedJobs(jobs, 30, NOW);
        assert.equal(result.removed, 2);
        assert.deepEqual(result.jobs.completed.map(j => j.jobId), ['recent']);
    });

    test('keeps a job exactly at the cutoff (>=, not >)', () => {
        const jobs = {
            completed: [
                { jobId: 'on-cutoff', endTime: daysAgoIso(30) },
            ],
        };
        const result = cleanCompletedJobs(jobs, 30, NOW);
        assert.equal(result.removed, 0);
        assert.equal(result.jobs.completed.length, 1);
    });

    test('falls back through endTime -> startTime -> created', () => {
        const jobs = {
            completed: [
                { jobId: 'byStart',   startTime: daysAgoIso(60) },
                { jobId: 'byCreated', created:   daysAgoIso(5) },
            ],
        };
        const result = cleanCompletedJobs(jobs, 30, NOW);
        assert.equal(result.removed, 1);
        assert.equal(result.jobs.completed[0].jobId, 'byCreated');
    });

    test('keeps jobs with no timestamp at all (safety default)', () => {
        const jobs = {
            completed: [
                { jobId: 'noDate' },
                { jobId: 'old', endTime: daysAgoIso(100) },
            ],
        };
        const result = cleanCompletedJobs(jobs, 30, NOW);
        assert.equal(result.removed, 1);
        assert.deepEqual(result.jobs.completed.map(j => j.jobId), ['noDate']);
    });

    test('handles missing completed array without throwing', () => {
        const jobs = {};
        const result = cleanCompletedJobs(jobs, 30, NOW);
        assert.equal(result.removed, 0);
        assert.equal(result.jobs, jobs);
    });

    test('defaults to COMPLETED_RETENTION_DAYS when retentionDays is omitted', () => {
        const jobs = {
            completed: [
                { jobId: 'stale', endTime: daysAgoIso(COMPLETED_RETENTION_DAYS + 5) },
                { jobId: 'ok',    endTime: daysAgoIso(1) },
            ],
        };
        const result = cleanCompletedJobs(jobs, undefined, NOW);
        assert.equal(result.retentionDays, COMPLETED_RETENTION_DAYS);
        assert.deepEqual(result.jobs.completed.map(j => j.jobId), ['ok']);
    });
});

describe('msToTime', () => {
    test('formats sub-minute durations as "S.Ms"', () => {
        assert.equal(msToTime(5300), '5.3s');
    });

    test('formats sub-hour durations as "Mm S.Ms"', () => {
        assert.equal(msToTime(90500), '1m 30.5s');
    });

    test('formats hour-plus durations as "Hh Mm S.Ms"', () => {
        // 1h 2m 3.4s
        assert.equal(msToTime(3600000 + 2 * 60000 + 3400), '1h 2m 3.4s');
    });

    test('handles zero', () => {
        assert.equal(msToTime(0), '0.0s');
    });
});
