#!/usr/bin/env node

import fs from 'fs';
import fetch from 'node-fetch';
import { Headers } from 'node-fetch';
import express from 'express';
import bodyParser from 'body-parser';
import multer from 'multer';
import sharp from 'sharp';
import { v4 as uuid } from 'uuid';
import cookieParser from 'cookie-parser';
import FormData from 'form-data';
import cron from "node-cron";
import PQueue from 'p-queue';
import * as helpers from './lib/helpers.js';
import { createWebexClient } from './lib/webex.js';
import { createTranslator } from './lib/translation.js';
import { createJobsPipeline } from './lib/jobs.js';

const queue = new PQueue({ concurrency: 10 });

//Load the config files from storage.
// config.json  = committed, structural (server port, bot labels, integration ids).
// authorized.json = gitignored, mutable per-user data:
//     { admins: [personId...], bot: { <appName>: { <personId>: { ...profile, groups: [...] } } } }
// jobs.json    = gitignored, runtime queue state.
// userPrefs.json = gitignored, per-recipient preferences (language, ...).
var config = JSON.parse(fs.readFileSync('./config/config.json'));
var jobs = JSON.parse(fs.readFileSync('./config/jobs.json'));
var userPrefs = JSON.parse(fs.readFileSync('./config/userPrefs.json'));
var authorized = loadAuthorized();

function loadAuthorized() {
    try {
        var doc = JSON.parse(fs.readFileSync('./config/authorized.json'));
        doc.admins = Array.isArray(doc.admins) ? doc.admins : [];
        doc.bot = (doc.bot && typeof doc.bot === 'object') ? doc.bot : {};
        return doc;
    } catch (err) {
        // Missing file (fresh deploy) is fine — we start empty and the admin
        // page can populate. Any other parse error is fatal because otherwise
        // the whole authorization layer silently permits nothing.
        if (err && err.code === 'ENOENT') {
            console.log('loadAuthorized: authorized.json not found, starting empty.');
            return { admins: [], bot: {} };
        }
        throw err;
    }
}

// Returns the mutable per-user record for (appName, personId), creating the
// bot bucket if this is the first write. Never returns null when personId is
// truthy — callers use this to add favorites / users without null-checking.
// Read-only callers should prefer helpers.getAuthorizedEntry(authorized, ...).
function getOrCreateAuthorizedEntry(appName, personId) {
    if (!authorized.bot[appName]) authorized.bot[appName] = {};
    return authorized.bot[appName][personId] || null;
}

// Defensive init so code below can safely `.push`, `.filter`, and index into
// these regardless of what the on-disk jobs.json happens to contain.
jobs.building = jobs.building || {};
jobs.running = Array.isArray(jobs.running) ? jobs.running : [];
jobs.scheduled = Array.isArray(jobs.scheduled) ? jobs.scheduled : [];
jobs.completed = Array.isArray(jobs.completed) ? jobs.completed : [];
if (typeof jobs.lastJobNumber !== 'number') jobs.lastJobNumber = 0;

// Per-bot tokens live in their own gitignored file so adding a new bot is one
// JSON entry + an env-free restart. Shape:
//   { "<appName>": { "token": "...", "enabled": true } }
var botTokens = loadBotTokens();

function loadBotTokens() {
    try {
        return JSON.parse(fs.readFileSync('./config/botTokens.json'));
    } catch (err) {
        console.log('loadBotTokens failed: ' + err.message);
        return {};
    }
}

function getBotToken(appName) {
    return helpers.getBotToken(botTokens, appName);
}

function isBotEnabled(appName) {
    return helpers.isBotEnabled(botTokens, appName);
}

// Returns the bot's config block if the bot is both defined in config.json AND
// has an enabled token entry. Returns null otherwise. Use this everywhere
// instead of reaching into config.webex.bot[...] directly so unknown or
// disabled bots can't crash the request.
function getBotConfig(appName) {
    return helpers.getBotConfig(config, botTokens, appName);
}

// Cached bot profiles populated from Webex /people/me at startup so the
// front-end and the completion card can use the bot's actual displayName and
// avatar without hardcoding either in config.json.
//   botProfiles[appName] = { displayName, avatar }
var botProfiles = {};

async function loadBotProfile(appName) {
    var token = getBotToken(appName);
    if (!token) return;
    try {
        var response = await webex.fetchWithRateLimit('https://webexapis.com/v1/people/me', {
            method: 'GET',
            headers: { 'Authorization': 'Bearer ' + token }
        });
        if (!response.ok) {
            logger('loadBotProfile', appName + ' /people/me failed: ' + response.status + ' ' + response.statusText);
            return;
        }
        var data = await response.json();
        botProfiles[appName] = {
            displayName: data.displayName,
            avatar: data.avatar,
            personId: data.id,
            emails: data.emails
        };
        logger('loadBotProfile', appName + ' = ' + data.displayName);
    } catch (err) {
        logger('loadBotProfile', appName + ' error: ' + (err && err.message || err));
    }
}

function loadBotProfiles() {
    var apps = Object.keys((config.webex && config.webex.bot) || {});
    return Promise.allSettled(apps.filter(isBotEnabled).map(loadBotProfile));
}

function getBotProfile(appName) {
    return botProfiles[appName] || null;
}

// Cached list of every Webex group in the org, used by the "Additional groups"
// dropdown on the compose page. findWebexGroup() paginates through ~10k groups
// 500 at a time, which used to run on every /user/groups/find request; we now
// refresh once at startup and once a day and serve everything else from
// memory. `refreshing` is a shared in-flight Promise so a concurrent refresh
// (e.g. the cron firing during a slow startup fetch) can't stampede.
var groupsCache = {
    data: null,
    lastRefreshed: null,
    refreshing: null,
};

function refreshGroupsCache() {
    if (groupsCache.refreshing) return groupsCache.refreshing;
    var startedAt = Date.now();
    logger('groupsCache', 'Refreshing group list from Webex...');
    groupsCache.refreshing = webex.findWebexGroup()
        .then(function (groups) {
            groupsCache.data = groups;
            groupsCache.lastRefreshed = new Date();
            logger('groupsCache',
                'Cached ' + groups.length + ' groups in ' +
                (Date.now() - startedAt) + 'ms.');
            return groups;
        })
        .catch(function (err) {
            logger('groupsCache', 'Refresh failed: ' + (err && err.message || err));
            throw err;
        })
        .finally(function () {
            groupsCache.refreshing = null;
        });
    return groupsCache.refreshing;
}

// Returns the cached groups if we have any; otherwise waits for whatever
// refresh is currently in flight (or kicks one off). Requests only ever block
// on the very first call after startup — every subsequent call resolves
// synchronously from memory.
function getGroupsFromCache() {
    if (groupsCache.data) return Promise.resolve(groupsCache.data);
    return refreshGroupsCache();
}

// Express middleware: 404s any /CollabCentral/:app/* request whose `:app` is
// not a known + enabled bot. Attaches the bot config to req.botConfig for
// downstream handlers.
function requireBot(req, res, next) {
    var botCfg = getBotConfig(req.params.app);
    if (!botCfg) {
        logger("requireBot", "Unknown or disabled bot: " + req.params.app);
        return res.status(404).send("Unknown bot: " + req.params.app);
    }
    req.botConfig = botCfg;
    next();
}

// Building (draft) jobs are keyed by cookieId AND appName so a user who is
// authorized for more than one bot can have one independent draft per bot.
function buildingKey(req) {
    return helpers.buildingKey(req.cookies && req.cookies.id, req.params.app);
}

function jobsForApp(arr, appName) {
    return helpers.jobsForApp(arr, appName);
}

// Service-account OAuth tokens are rewritten in place by refreshToken(), so
// they live in their own file rather than mixed into config.json.
var serviceAccountToken = loadServiceAccountToken();

function loadServiceAccountToken() {
    try {
        return JSON.parse(fs.readFileSync('./config/token.json'));
    } catch (err) {
        console.log('loadServiceAccountToken failed: ' + err.message);
        return {};
    }
}

function saveServiceAccountToken(tokenObj) {
    // Keep the in-memory copy in sync even if the disk write fails so the
    // refreshed token is still usable for the rest of the process lifetime.
    serviceAccountToken = tokenObj;
    try {
        fs.writeFileSync('./config/token.json', JSON.stringify(tokenObj, null, 4));
    } catch (err) {
        logger("saveServiceAccountToken", "Failed to persist token.json: " + err.message);
    }
}

function getServiceAccountAccessToken() {
    return serviceAccountToken && serviceAccountToken.access_token;
}

// Builds the Webex OAuth authorize URL for a given bot. The redirect_uri must
// match one of the URIs registered with the Webex integration in the Webex
// developer portal (one URI per bot). Returns null if required env vars are
// missing so callers can return an actionable error instead of a malformed URL.
function buildAuthUrl(appName) {
    var url = helpers.buildAuthUrl({
        clientId: process.env.WEBEX_INTEGRATION_CLIENT_ID,
        template: process.env.OAUTH_CALLBACK_URL_TEMPLATE,
        appName: appName,
    });
    if (!url) logger('buildAuthUrl', 'Missing WEBEX_INTEGRATION_CLIENT_ID or OAUTH_CALLBACK_URL_TEMPLATE env var.');
    return url;
}

function getOAuthRedirectUri(appName) {
    return helpers.getOAuthRedirectUri(process.env.OAUTH_CALLBACK_URL_TEMPLATE, appName);
}

// ---------------------------------------------------------------------------
// Wire the lib/* modules. Each takes a dependency bag so the modules
// themselves stay free of module-level mutable state — everything mutable
// (tokens, jobs, queue) still lives here in index.js.
// ---------------------------------------------------------------------------
var webex = createWebexClient({
    getBotToken: getBotToken,
    getServiceAccountAccessToken: getServiceAccountAccessToken,
    getServiceAccountRefreshToken: function () { return serviceAccountToken && serviceAccountToken.refresh_token; },
    saveServiceAccountToken: saveServiceAccountToken,
    env: process.env,
    logger: logger,
});

var translator = createTranslator({
    languages: config.languages || [],
    apiKey: process.env.GOOGLE_TRANSLATE_API_KEY || '',
    fetchWithRateLimit: webex.fetchWithRateLimit,
    logger: logger,
});

var jobsPipeline = createJobsPipeline({
    jobs: jobs,
    queue: queue,
    userPrefs: userPrefs,
    webex: webex,
    getBotConfig: function (appName) { return helpers.getBotConfig(config, botTokens, appName); },
    getBotProfile: getBotProfile,
    saveJobs: function () { saveConfig(jobs, './config/jobs.json'); },
    msToTime: helpers.msToTime,
    logger: logger,
});

//Load Express Server
var app = express();
app.use(bodyParser.json());
app.use(cookieParser());

var serverPort = process.env.SERVER_PORT || config.server.port;
var server = app.listen(serverPort, function () {
    logger("startup", config.server.name + " running on port " + serverPort + ".");
    loadBotProfiles()
        .then(() => logger("startup", "Loaded " + Object.keys(botProfiles).length + " bot profile(s)."))
        .catch(err => logger("startup", "loadBotProfiles error: " + (err && err.message || err)));
    // Warm the groups cache in the background so the very first user hitting
    // /user/groups/find gets an instant response. Failures are non-fatal:
    // getGroupsFromCache() will simply try again on the first request.
    refreshGroupsCache().catch(function () { /* logged inside */ });
});
server.setTimeout(3000000);
const upload = multer({ limits: { fileSize: 4000000 } }).fields(
    [
        { name: 'uploadImage' },
        { name: 'uploadCSV' }
    ]
);

// Anchor both crons to a specific timezone so the schedule is deterministic
// regardless of the host/container clock. Override with CRON_TIMEZONE if the
// deployment moves to a different region.
var cronTimezone = process.env.CRON_TIMEZONE || 'America/New_York';

// Daily at 01:10 local time: prune completed jobs older than the retention
// window and rewrite jobs.json.
cron.schedule('0 10 1 * * *', async function () {
    var jobFile = JSON.parse(fs.readFileSync('./config/jobs.json'));
    var cleanJobs = await cleanCompletedJobs(jobFile);
    saveConfig(cleanJobs, './config/jobs.json');
    jobs = JSON.parse(fs.readFileSync('./config/jobs.json'));

}, { timezone: cronTimezone })

// Every minute: refresh the service-account token if it's inside the renewal
// window, and dispatch any scheduled jobs whose time has arrived.
cron.schedule('0 * * * * *', () => {
    var renewBy = serviceAccountToken && serviceAccountToken.renewBy;
    if (renewBy && new Date(new Date(renewBy) - 7200000) < new Date()) {
        logger("refreshToken", "Renew by: " + new Date(renewBy).toLocaleString())
        logger("refreshToken", "Renew after: " + new Date(new Date(renewBy) - 7200000).toLocaleString());
        logger("refreshToken", "Refreshing token.")
        webex.refreshToken()
            .then(response => {
                logger("tokenRefresh", response);
            })
            .catch(error => logger("refreshToken", error));
    }
    jobsPipeline.checkScheduledJobs()
        .catch(error => logger("checkScheduledJobs", error));
}, { timezone: cronTimezone })

// Daily at 03:00 local time: rebuild the org-wide groups cache. Offset from
// the 01:10 jobs cleanup so the two crons don't fight over the event loop.
cron.schedule('0 0 3 * * *', function () {
    refreshGroupsCache().catch(function () { /* logged inside */ });
}, { timezone: cronTimezone })

//Routes to be used
// Validate the :app segment for every /CollabCentral/:app/* request. This
// runs before the static mount and all the per-route handlers below, so any
// unknown or disabled bot gets a clean 404 instead of crashing on undefined.
app.use('/CollabCentral/:app', requireBot)
app.use('/CollabCentral/:app', express.static('html'))

app.get('/status', function (req, res) {
    res.status(200).send({ "status": "I'm alive!'" });
});

app.get('/CollabCentral/:app/authUrl', function (req, res) {
    var url = buildAuthUrl(req.params.app);
    if (!url) {
        return res.status(500).send('OAuth is not configured. Check WEBEX_INTEGRATION_CLIENT_ID and OAUTH_CALLBACK_URL_TEMPLATE.');
    }
    res.send(url);
});

// Returns the bot's display metadata + whether the current cookie is authorized.
// The front-end uses this to drive page labels, icons, and the
// "you don't have access" view, so adding a new bot doesn't require any code
// changes in the HTML/JS.
app.get('/CollabCentral/:app/info', function (req, res) {
    var appName = req.params.app;
    var botCfg = req.botConfig; // populated by requireBot
    var profile = getBotProfile(appName) || {};
    var iconBase = botCfg.iconBase || appName;
    var personId = req.cookies && req.cookies.id;
    res.status(200).send({
        appName: appName,
        label: botCfg.label || appName,
        displayName: profile.displayName || botCfg.label || appName,
        iconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.png',
        faviconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.ico',
        avatarUrl: profile.avatar || null,
        authorized: isAuthorized(appName, personId),
        isAdmin: isAdmin(personId)
    });
});

app.post('/CollabCentral/:app/jobs/:action', (req, res) => {
    if (isAuthorized(req.params.app, req.cookies.id)) {
        req.setTimeout(3000000);
        if (req.params.action == "edit") {
            logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/edit");
            buildJob(req, res)
                .then(job => {
                    webex.sendDirectMessage(job.senderId, job.message.raw, job.imageName, req.params.app)
                        .then(async function (result) {
                            jobs.building[buildingKey(req)].message.english = {
                                "text": result.text,
                                "markdown": result.markdown,
                                "html": result.html
                            }
                            // Remember the preview DM's Webex message id so
                            // /jobs/cancel can retract it and /jobs/runNow can
                            // treat it as "already delivered" (future).
                            if (result && result.id) {
                                jobs.building[buildingKey(req)].previewMessageId = result.id;
                            }

                            await translator.buildTranslations(jobs.building[buildingKey(req)].message.english)
                                .then(result => {
                                    jobs.building[buildingKey(req)].message = result;
                                })
                                .catch(error => console.log("Error translating: " + error));

                            res.status(200).send(job)
                            saveConfig(jobs, "./config/jobs.json")
                        })
                        .catch(error => console.log("Error sending message: " + error));
                })
                .catch(error => console.log("Error buildJob: " + error));
        } else if (req.params.action == "runNow") {
            logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/runNow");
            jobs.running.push(jobs.building[buildingKey(req)]);
            delete jobs.building[buildingKey(req)];
            jobsPipeline.processRunningQueue()
                .catch(error => console.log("sendRunningMessages error: " + error))
            res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html");
        } else if (req.params.action == "schedule") {
            logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/schedule");
            jobs.scheduled.push(jobs.building[buildingKey(req)]);
            delete jobs.building[buildingKey(req)];
            saveConfig(jobs, './config/jobs.json')
            res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html");
        } else if (req.params.action == "cancel") {
            // Abandon the caller's in-progress "building" job for this bot.
            // A building entry exists as soon as the user clicks "Review &
            // send" (POST /jobs/edit) and lingers on the server until the
            // user promotes it to running/scheduled — or until now, where
            // this route just drops it on the floor. Idempotent: 200 whether
            // or not a building entry actually existed for (personId, app),
            // so the client can always fire this on "cancel" without needing
            // to check state first.
            //
            // If the building job has a previewMessageId (captured during
            // /jobs/edit), also retract that DM from the sender's own space
            // so the review message doesn't hang around after they discarded
            // the draft. Best-effort — a failed Webex delete is logged but
            // does not fail the cancel.
            logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel");
            var key = buildingKey(req);
            var job = jobs.building[key];
            var hadJob = !!job;
            if (hadJob) {
                var previewId = job.previewMessageId;
                delete jobs.building[key];
                try {
                    saveConfig(jobs, './config/jobs.json');
                } catch (err) {
                    logger('apiEndpoint(' + req.params.app + ')',
                        'jobs/cancel save failed: ' + (err && err.message || err));
                    return res.status(500).send('Failed to cancel job.');
                }
                if (previewId) {
                    // Fire-and-forget; we've already committed the cancel
                    // to disk and we don't want a slow Webex round trip to
                    // hold the response.
                    webex.deleteMessage(previewId, req.params.app);
                }
            }
            return res.status(200).send({ cancelled: hadJob });
        } else { res.status(404) }
    } else { res.status(401).send("You are not authorized.") }

})

app.get('/CollabCentral/:app/jobs/list/:scope', (req, res) => {
    if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) {
        return res.status(401).send("You are not authorized.");
    }
    var appName = req.params.app;

    if (req.params.scope == "all") {
        logger("apiEndpoint(" + appName + ")", "GET /jobs/list/all");
        return res.status(200).send({
            building: jobs.building[buildingKey(req)],
            running: jobsForApp(jobs.running, appName),
            scheduled: jobsForApp(jobs.scheduled, appName),
            completed: jobsForApp(jobs.completed, appName)
        });
    }

    if (req.params.scope == "building") {
        logger("apiEndpoint(" + appName + ")", "GET /jobs/list/building");
        return res.status(200).send(jobs.building[buildingKey(req)]);
    }

    if (req.params.scope == "running") {
        logger("apiEndpoint(" + appName + ")", "GET /jobs/list/running");
        var runningJobs = [];
        for (var job of jobsForApp(jobs.running, appName)) {
            var completedSends = 0;
            for (var member of (job.memberList || [])) {
                if (member.results) completedSends++;
            }
            runningJobs.push({
                jobId: job.jobId,
                senderDisplayName: job.senderDisplayName,
                appName: job.appName,
                startTime: job.startTime,
                totalRecipients: (job.memberList || []).length,
                completedRecipients: completedSends
            });
        }
        return res.status(200).send(runningJobs);
    }

    if (req.params.scope == "scheduled") {
        logger("apiEndpoint(" + appName + ")", "GET /jobs/list/scheduled");
        var scheduledJobs = [];
        for (var job of jobsForApp(jobs.scheduled, appName)) {
            scheduledJobs.push({
                senderDisplayName: job.senderDisplayName,
                appName: job.appName,
                message: job.message && job.message.english && job.message.english.html,
                totalRecipients: (job.memberList || []).length,
                scheduledFor: job.scheduledFor
            });
        }
        return res.status(200).send(scheduledJobs);
    }

    if (req.params.scope == "completed") {
        logger("apiEndpoint(" + appName + ")", "GET /jobs/list/completed");
        return res.status(200).send(jobsForApp(jobs.completed, appName));
    }

    return res.status(404).send();
});

// Returns the full job object for a given jobId, but only if the job belongs
// to the requested bot. Looks across completed/running/scheduled so the same
// URL keeps working as a job moves through its lifecycle.
app.get('/CollabCentral/:app/jobs/detail/:jobId', (req, res) => {
    if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) {
        return res.status(401).send("You are not authorized.");
    }
    var appName = req.params.app;
    var jobId = req.params.jobId;
    logger("apiEndpoint(" + appName + ")", "GET /jobs/detail/" + jobId);

    var all = (jobs.completed || []).concat(jobs.running || [], jobs.scheduled || []);
    var match = all.find(function (j) {
        return j && j.appName === appName && String(j.jobId) === String(jobId);
    });
    if (!match) return res.status(404).send("Job not found.");
    res.status(200).send(match);
});

app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
    console.log("Got /user/:scope/:action request.");
    //console.log(req.cookies);
    if (isAuthorized(req.params.app, req.cookies.id)) {
        //console.log("Person is authorized for the request.")
        if (req.params.scope == "groups") {
            if (req.params.action == "list") {
                logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/list");
                var listEntry = helpers.getAuthorizedEntry(authorized, req.params.app, req.cookies.id);
                res.status(200).send((listEntry && listEntry.groups) || []);
            } else if (req.params.action == "find") {
                logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find");
                getGroupsFromCache()
                    .then(function (groups) { res.status(200).send(groups); })
                    .catch(function (err) {
                        logger('apiEndpoint(' + req.params.app + ')',
                            'groups/find failed: ' + (err && err.message || err));
                        res.status(502).send('Failed to load groups from Webex.');
                    });
            }
        } else { res.status(404).send('Not found.'); }
    } else { res.status(401).send('Unauthorized.'); }
})

// Add / remove a favorite group for the calling user. Favorites live inside
// authorized.json under `bot[app][personId].groups` so they survive restarts
// and are picklable in the compose page's Favorite Groups selector. Both
// endpoints validate that:
//   - the caller is authorized for :app
//   - :id looks like a Webex SCIM group id we know about (present in the
//     cached org-wide group list) — this stops a bad actor from stuffing
//     arbitrary strings into the config.
// Because index.js is single-process and node is single-threaded, concurrent
// requests here can't corrupt authorized.json — every read/mutate/write
// executes atomically within one handler.
app.post('/CollabCentral/:app/user/groups/add', async function (req, res) {
    if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
    var appName = req.params.app;
    var personId = req.cookies.id;
    var groupId = req.body && req.body.id;
    if (!groupId) return res.status(400).send('Missing group id.');

    var group;
    try {
        var allGroups = await getGroupsFromCache();
        group = allGroups.find(function (g) { return g.id === groupId; });
    } catch (err) {
        logger('apiEndpoint(' + appName + ')',
            'groups/add cache lookup failed: ' + (err && err.message || err));
        return res.status(502).send('Group list unavailable.');
    }
    if (!group) return res.status(404).send('Unknown group id.');

    var entry = helpers.getAuthorizedEntry(authorized, appName, personId);
    // isAuthorized guarantees the entry exists but be defensive; a race
    // between an admin removal and this request could otherwise crash.
    if (!entry) return res.status(401).send('Unauthorized.');
    var favorites = Array.isArray(entry.groups) ? entry.groups : [];
    if (favorites.find(function (g) { return g.id === groupId; })) {
        return res.status(200).send(favorites);
    }
    favorites.push({ name: group.displayName, id: group.id });
    entry.groups = favorites;
    try {
        saveConfig(authorized, './config/authorized.json');
    } catch (err) {
        logger('apiEndpoint(' + appName + ')',
            'groups/add save failed: ' + (err && err.message || err));
        return res.status(500).send('Failed to save favorite.');
    }
    logger('apiEndpoint(' + appName + ')',
        'groups/add ' + personId.slice(-8) + ' + "' + group.displayName + '"');
    res.status(200).send(favorites);
});

app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
    if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
    var appName = req.params.app;
    var personId = req.cookies.id;
    var groupId = req.body && req.body.id;
    if (!groupId) return res.status(400).send('Missing group id.');

    var entry = helpers.getAuthorizedEntry(authorized, appName, personId);
    if (!entry) return res.status(401).send('Unauthorized.');
    var favorites = Array.isArray(entry.groups) ? entry.groups : [];
    var before = favorites.length;
    var removed = favorites.find(function (g) { return g.id === groupId; });
    favorites = favorites.filter(function (g) { return g.id !== groupId; });
    if (favorites.length === before) return res.status(200).send(favorites);

    entry.groups = favorites;
    try {
        saveConfig(authorized, './config/authorized.json');
    } catch (err) {
        logger('apiEndpoint(' + appName + ')',
            'groups/remove save failed: ' + (err && err.message || err));
        return res.status(500).send('Failed to remove favorite.');
    }
    logger('apiEndpoint(' + appName + ')',
        'groups/remove ' + personId.slice(-8) + ' - "' + (removed && removed.name || groupId) + '"');
    res.status(200).send(favorites);
});

// ---- Admin: manage the authorized-user list for a bot ---------------------
//
// Admin authority lives in authorized.admins (personIds). Every admin route
// rejects with 403 for non-admin callers — 403 rather than 401 so the client
// can tell an admin-only route apart from a signed-out state (which would
// 401). Admin actions are cross-bot in concept but the routes are still
// :app-scoped because the resource being edited is per-bot (authorized users
// on THAT bot) and it keeps the OAuth session model unchanged (admin uses
// the same session cookie as any other page).
//
// requireAdmin returns null on success or an Express-response-sending
// function on failure, so each handler stays a straight-line function.
function requireAdmin(req, res) {
    if (!req.cookies || !req.cookies.id) return res.status(401).send('Unauthorized.');
    if (!isAdmin(req.cookies.id)) return res.status(403).send('Admin only.');
    return null;
}

// Shape of a single row returned by the admin users endpoints. Kept in one
// helper so add / delete / list all produce identical output — the client
// can treat every response as an authoritative "here's the current state".
function adminUserRow(entry) {
    var groups = Array.isArray(entry && entry.groups) ? entry.groups : [];
    return {
        id: entry.id,
        displayName: entry.displayName || '',
        email: entry.email || '',
        avatar: entry.avatar || null,
        groupCount: groups.length
    };
}

function adminUsersList(appName) {
    var bucket = (authorized.bot && authorized.bot[appName]) || {};
    return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); });
}

app.get('/CollabCentral/:app/admin/users', function (req, res) {
    if (requireAdmin(req, res)) return;
    logger('apiEndpoint(' + req.params.app + ')', 'GET /admin/users');
    res.status(200).send(adminUsersList(req.params.app));
});

app.post('/CollabCentral/:app/admin/users', async function (req, res) {
    if (requireAdmin(req, res)) return;
    var appName = req.params.app;
    var email = req.body && req.body.email && String(req.body.email).trim();
    if (!email) return res.status(400).send('Missing email.');

    var person;
    try {
        person = await webex.findPersonByEmail(email);
    } catch (err) {
        logger('apiEndpoint(' + appName + ')',
            'admin/users lookup failed for "' + email + '": ' + (err && err.message || err));
        return res.status(502).send('Failed to reach Webex directory.');
    }
    if (!person) return res.status(404).send('No Webex user found for that email.');

    if (!authorized.bot[appName]) authorized.bot[appName] = {};
    var bucket = authorized.bot[appName];
    if (!bucket[person.id]) {
        bucket[person.id] = {
            id: person.id,
            displayName: person.displayName,
            email: person.email,
            avatar: person.avatar,
            groups: []
        };
        try {
            saveConfig(authorized, './config/authorized.json');
        } catch (err) {
            logger('apiEndpoint(' + appName + ')',
                'admin/users save failed: ' + (err && err.message || err));
            return res.status(500).send('Failed to save.');
        }
        logger('apiEndpoint(' + appName + ')',
            'admin/users + "' + person.displayName + '" <' + person.email + '>');
    }
    res.status(200).send({
        user: adminUserRow(bucket[person.id]),
        users: adminUsersList(appName)
    });
});

app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) {
    if (requireAdmin(req, res)) return;
    var appName = req.params.app;
    var targetId = req.params.id;
    var bucket = (authorized.bot && authorized.bot[appName]) || {};
    if (!bucket[targetId]) {
        return res.status(200).send({ removed: false, users: adminUsersList(appName) });
    }
    var name = bucket[targetId].displayName || targetId;
    delete bucket[targetId];
    try {
        saveConfig(authorized, './config/authorized.json');
    } catch (err) {
        logger('apiEndpoint(' + appName + ')',
            'admin/users delete save failed: ' + (err && err.message || err));
        return res.status(500).send('Failed to save.');
    }
    logger('apiEndpoint(' + appName + ')', 'admin/users - "' + name + '"');
    res.status(200).send({ removed: true, users: adminUsersList(appName) });
});

// Options for the session cookies set after a successful OAuth round-trip.
//
// httpOnly is deliberately false: js/app.js reads `id` (to decide whether to
// redirect to OAuth) and `displayName` (to render the user chip in the header)
// via document.cookie. Enabling httpOnly here hides the cookies from JS, which
// causes an infinite auth loop where every page load thinks the user is not
// signed in and kicks off a fresh OAuth exchange — burning Webex tokens until
// the CTS token limit is reached.
//
// sameSite is 'lax' rather than 'strict' so the cookie survives the OAuth
// redirect chain webexapis.com -> /oauth -> /sendMessage.html on all browsers.
const SESSION_COOKIE_OPTIONS = {
    httpOnly: false,
    secure: true,
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000
};

app.get(`/CollabCentral/:app/oauth`, async function (req, res) {
    var appName = req.params.app;
    var authCode = req.query.code;

    if (!process.env.WEBEX_INTEGRATION_CLIENT_ID || !process.env.WEBEX_INTEGRATION_CLIENT_SECRET || !process.env.OAUTH_CALLBACK_URL_TEMPLATE) {
        logger("oauth", "Missing Webex integration env vars; cannot complete OAuth.");
        return res.status(500).send("OAuth is not configured on the server.");
    }
    if (!authCode) {
        return res.status(400).send("Missing OAuth `code` query parameter.");
    }

    var url = new URL("https://webexapis.com/v1/access_token");
    var params = new URLSearchParams();
    params.append('grant_type', 'authorization_code');
    params.append('client_id', process.env.WEBEX_INTEGRATION_CLIENT_ID);
    params.append('client_secret', process.env.WEBEX_INTEGRATION_CLIENT_SECRET);
    params.append('code', authCode);
    params.append('redirect_uri', getOAuthRedirectUri(appName));

    var response;
    try {
        response = await webex.fetchWithRateLimit(url, { method: 'POST', body: params });
    } catch (error) {
        logger("oauth", "Token exchange fetch failed: " + (error && error.message || error));
        return res.status(502).send("Failed to reach Webex to complete OAuth.");
    }

    if (!response.ok) {
        var errText = await response.text().catch(() => '');
        logger("oauth", "Token exchange returned " + response.status + " " + response.statusText + " " + errText);
        return res.status(401).send(response.statusText || "OAuth token exchange failed.");
    }

    var jsonData = await response.json();
    var whoami;
    try {
        whoami = await webex.whoAmI(jsonData.access_token);
    } catch (err) {
        logger("oauth", "whoAmI failed: " + (err && err.message || err));
        return res.status(502).send("Failed to read your Webex profile after OAuth.");
    }
    logger("oauth", whoami.displayName + " successfully authed for " + appName + ".");

    // Only the two cookies that the server (req.cookies.id, .displayName) and
    // the client (getCookie('id'), getCookie('displayName')) actually read.
    // Access/refresh tokens deliberately stay out of the browser: they never
    // need to leave the server, and putting them in cookies would expose them
    // to any XSS that might slip in later.
    res
        .cookie('id', whoami.id, SESSION_COOKIE_OPTIONS)
        .cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS)
        .redirect(301, '/CollabCentral/' + appName + '/sendMessage.html');
});

function buildJob(req, res) {
    return new Promise(async function (resolve, reject) {
        res.setTimeout(3000000)
        var memberList = [];
        var memberListPromises = [];

        upload(req, res, async function (err) {
            //console.log(req.files);
            //console.log(req.body);
            jobs.building[buildingKey(req)] = {
                "appName": req.body.appName,
                "senderId": req.cookies.id,
                "senderDisplayName": req.cookies.displayName,
                "groups": req.body.groups,
                "message": {
                    "raw": req.body.message,
                    "english": {}
                },
                "memberList": []
            }
            // check for error thrown by multer- file size etc

            if (err || req.files === undefined) {
                //no file
            } else {
                if (req.files["uploadImage"]) {
                    for (var file of req.files["uploadImage"]) {

                        let fileName = uuid() + ".jpeg"
                        var image = await sharp(file.buffer).jpeg({ quality: 40, }).toFile('./uploads/' + fileName).catch(err => { console.log('error: ', err) })
                        jobs.building[buildingKey(req)].imageName = fileName;
                        logger("buildJob", "uploadImage " + file.originalname + " to " + fileName)
                    }
                }

                if (req.files["uploadCSV"]) {
                    for (var file of req.files["uploadCSV"]) {
                        let fileName = uuid() + ".csv";
                        fs.writeFileSync('./uploads/' + fileName, file.buffer)
                        jobs.building[buildingKey(req)].csv = fileName;
                        logger("buildJob", "uploadCSV " + file.originalname + " to " + fileName)
                        memberListPromises.push(jobsPipeline.buildPeopleList(fileName));
                    }
                }
            }

            if (req.body.groups) {
                logger("buildJob", "Locating groups: " + req.body.groups);
                memberListPromises.push(jobsPipeline.collectGroupMembers(req.body.groups));
            }

            if (req.body.scheduledFor) {
                logger("buildJob", "Scheduling for " + new Date(req.body.scheduledFor).toLocaleString())
                jobs.building[buildingKey(req)].scheduledFor = req.body.scheduledFor;
                saveConfig(jobs, "./config/jobs.json")
            }

            Promise.allSettled(memberListPromises)
                .then(peoplePromises => {
                    console.log(JSON.stringify(peoplePromises))
                    for (var peopleList of peoplePromises) {
                        if (peopleList.status == "fulfilled") {
                            for (var person of peopleList.value) {
                                memberList.push(person);
                            }
                        }
                    }
                    //Make user members unique
                    var uniqueMembers = [...new Map(memberList.map((m) => [m.id, m])).values()];
                    jobs.building[buildingKey(req)].memberList = uniqueMembers;
                    var numSending = uniqueMembers.length.toString();
                    saveConfig(jobs, "./config/jobs.json")
                    resolve(jobs.building[buildingKey(req)]);
                })
        })

    })
}

function saveConfig(jsonObject, configFile) {
    const jsonData = JSON.stringify(jsonObject, null, 4);
    fs.writeFileSync(configFile, jsonData, (err) => {
        if (err) {
            throw err;
        } else {
            logger("saveConfig", "Wrote " + configFile);
        }
    })
}

// Retention window for completed jobs (re-exported from lib/helpers.js so
// index.js has a single symbol name callers can reason about). Bump this by
// editing helpers.js if you want a longer / shorter review window.
const COMPLETED_RETENTION_DAYS = helpers.COMPLETED_RETENTION_DAYS;

async function cleanCompletedJobs(jobs) {
    var result = helpers.cleanCompletedJobs(jobs, COMPLETED_RETENTION_DAYS);
    if (!Array.isArray(result.jobs.completed)) {
        logger('cleanCompletedJobs', 'No completed array found – nothing to do.');
    } else {
        logger('cleanCompletedJobs',
            'Removed ' + result.removed + ' job(s) older than ' + COMPLETED_RETENTION_DAYS +
            ' days (cutoff ' + result.cutoff.toISOString().split('T')[0] + '). Remaining: ' +
            result.jobs.completed.length);
    }
    return result.jobs;
}

function isAuthorized(appName, personId) {
    logger("isAuthorized", appName + " " + personId)
    return helpers.isAuthorized(config, botTokens, authorized, appName, personId);
}

function isAdmin(personId) {
    return helpers.isAdmin(authorized, personId);
}

function logger(activeFunction, logLine) {
    var d = new Date();
    console.log(d.toLocaleString() + " " + activeFunction + ": " + logLine);
}

// gracefully shutdown (ctrl-c)
process.on('SIGINT', function () {
    server.close(() => {
        logger("shutdown", config.server.name + ' stopped!')
        process.exit();
    });
})
