/* ----------------------------------------------------------------------------

 Question Server
 GitLab: <https://gitlab.com/MrFry/mrfrys-node-server>

 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 This program is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with this program. If not, see <https://www.gnu.org/licenses/>.

 ------------------------------------------------------------------------- */

import { Response } from 'express'

import logger from '../../../utils/logger'
import {
    Request,
    SubmoduleData,
    Submodule,
    PeerInfo,
} from '../../../types/basicTypes'
import utils from '../../../utils/utils'
import dbtools from '../../../utils/dbtools'
import { createKeyPair, isKeypairValid } from '../../../utils/encryption'
import { countOfQdbs } from '../../../utils/qdbUtils'
import { files, paths, readAndValidateFile } from '../../../utils/files'
import { GetResult, get } from '../../../utils/networkUtils'
import { setPendingJobsAlertCount } from '../../../worker/workerPool'
import {
    RemotePeerInfo,
    SyncDataResult,
    SyncResult,
    isPeerSameAs,
    loginToPeer,
    peerToString,
    updatePeersFile,
    updateThirdPartyPeers,
} from '../p2p/p2putils'
import { getQuestions, syncQuestions } from '../p2p/questions'
import { getUsers, handleNewUsers, syncUsers } from '../p2p/users'
import {
    NewUserFilesRequestBody,
    getUserFiles,
    handleNewUserFiles,
    syncUserFiles,
} from '../p2p/userFiles'

async function authAndGetNewData({
    peer,
    selfInfo,
    allTime,
    shouldSync,
}: {
    peer: PeerInfo
    selfInfo: PeerInfo
    allTime?: boolean
    shouldSync: {
        questions: boolean
        users: boolean
        userFiles: boolean
    }
}): Promise<GetResult<SyncDataResult> & { peer: PeerInfo }> {
    try {
        const syncAll =
            !shouldSync ||
            Object.values(shouldSync).filter((x) => x).length === 0
        let sessionCookie = peer.sessionCookie

        const login = async () => {
            const loginResult = await loginToPeer(peer)
            if (typeof loginResult === 'string') {
                sessionCookie = loginResult
                updatePeersFile(peer, { sessionCookie: loginResult })
            } else {
                throw {
                    error: loginResult,
                    data: {
                        peer: peer,
                    },
                }
            }
        }

        if (!sessionCookie) {
            await login()
        }

        const getData = async <T>(path: string) => {
            return get<T>(
                {
                    headers: {
                        cookie: `sessionID=${sessionCookie}`,
                    },
                    host: peer.host,
                    port: peer.port,
                    path: path,
                },
                peer.http
            )
        }

        let result: GetResult<SyncDataResult>

        const setResult = async () => {
            let url = `/api/getnewdata?host=${encodeURIComponent(
                peerToString(selfInfo)
            )}`

            if (!allTime) {
                url += `&questionsSince=${peer.lastQuestionsSync}`
                url += `&usersSince=${peer.lastUsersSync}`
                url += `&userFilesSince=${peer.lastUserFilesSync}`
            }

            if (!syncAll) {
                if (shouldSync.questions) {
                    url += '&questions=true'
                }
                if (shouldSync.users) {
                    url += '&users=true'
                }
                if (shouldSync.userFiles) {
                    url += '&userFiles=true'
                }
            }

            result = await getData<SyncDataResult>(url)
        }

        await setResult()

        const hasNoUser = Object.values(result).find((res) => {
            return res.data?.result === 'nouser'
        })
        if (hasNoUser) {
            await login()
            result = {}
            await setResult()
        }

        return { ...result, peer: peer }
    } catch (e) {
        console.error(e)
        return { error: e, peer: peer }
    }
}

function handleNewThirdPartyPeer(remoteHost: string) {
    logger.Log(
        'Couldn\'t find remote peer info based on remoteHost: "' +
            remoteHost +
            '". This could mean that the host uses this server as peer, but this server does not ' +
            'use it as a peer.',
        'yellowbg'
    )
    if (remoteHost.includes(':')) {
        const [host, port] = remoteHost.split(':')
        updateThirdPartyPeers([
            {
                host: host,
                port: +port,
            },
        ])
        logger.Log('Host info written to host info file')
    }
}

function setup(data: SubmoduleData): Submodule {
    const {
        app,
        userDB,
        moduleSpecificData: { setQuestionDbs, getQuestionDbs, dbsFile },
    } = data

    let syncInProgress = false

    // ---------------------------------------------------------------------------------------
    // SETUP
    // ---------------------------------------------------------------------------------------

    let publicKey: string
    let privateKey: string

    if (
        !utils.FileExists(paths.keyFile + '.priv') ||
        !utils.FileExists(paths.keyFile + '.pub')
    ) {
        createKeyPair().then(({ publicKey: pubk, privateKey: privk }) => {
            // at first start there won't be a keypair available until this finishes
            utils.WriteFile(pubk, paths.keyFile + '.pub')
            utils.WriteFile(privk, paths.keyFile + '.priv')

            publicKey = pubk
            privateKey = privk
        })
        logger.Log(
            'There were no public / private keys for p2p functionality, created new ones',
            'yellowbg'
        )
    } else {
        publicKey = utils.ReadFile(paths.keyFile + '.pub')
        privateKey = utils.ReadFile(paths.keyFile + '.priv')
        // checking only here, because if it got generated in the other branch then it must be good
        if (!isKeypairValid(publicKey, privateKey)) {
            logger.Log('Loaded keypair is not valid!', 'redbg')
        }
    }

    let peers: PeerInfo[] = utils.ReadJSON(paths.peersFile)
    let selfInfo: PeerInfo = utils.ReadJSON(paths.selfInfoFile)
    selfInfo.publicKey = publicKey

    const filesToWatch = [
        {
            fname: paths.peersFile,
            logMsg: 'Peers file updated',
            action: () => {
                const newVal = readAndValidateFile<PeerInfo[]>(files.peersFile)
                if (newVal) {
                    peers = newVal
                }
            },
        },
        {
            fname: paths.selfInfoFile,
            logMsg: 'P2P self info file changed',
            action: () => {
                const newVal = readAndValidateFile<PeerInfo>(files.selfInfoFile)
                if (newVal) {
                    selfInfo = newVal
                }
            },
        },
    ]

    filesToWatch.forEach((ftw) => {
        if (utils.FileExists(ftw.fname)) {
            utils.WatchFile(ftw.fname, () => {
                logger.Log(ftw.logMsg)
                ftw.action()
            })
            ftw.action()
        } else {
            logger.Log(`File ${ftw.fname} does not exists to watch!`, 'redbg')
        }
    })

    if (peers.length === 0) {
        logger.Log(
            `Warning: peers file is empty. You probably want to fill it`,
            'yellowbg'
        )
    }

    // ---------------------------------------------------------------------------------------
    // FUNCTIONS
    // ---------------------------------------------------------------------------------------

    function getSelfInfo(includeVerboseInfo?: boolean) {
        const result: RemotePeerInfo = {
            selfInfo: { ...selfInfo, publicKey: publicKey },
            myPeers: peers.map((peer) => {
                // eslint-disable-next-line @typescript-eslint/no-unused-vars
                const { pw, sessionCookie, ...restOfPeer } = peer
                return restOfPeer
            }),
        }

        if (includeVerboseInfo) {
            const serverRevision = utils.getGitInfo(__dirname)
            result.serverRevision = serverRevision.revision
            result.serverLastCommitDate = serverRevision.lastCommitDate

            const scriptRevision = utils.getGitInfo(
                paths.moodleTestUserscriptDir
            )
            result.scriptRevision = scriptRevision.revision
            result.scriptLastCommitDate = scriptRevision.lastCommitDate

            const qminingPageRevision = utils.getGitInfo(paths.qminingPageDir)
            result.qminingPageRevision = qminingPageRevision.revision
            result.qminingPageLastCommitDate =
                qminingPageRevision.lastCommitDate

            const dataEditorRevision = utils.getGitInfo(paths.dataEditorPageDir)
            result.dataEditorRevision = dataEditorRevision.revision
            result.dataEditorLastCommitDate = dataEditorRevision.lastCommitDate

            result.qminingPageBuildTime = utils
                .statFile(paths.qminingIndexPath)
                ?.mtime.getTime()
            result.serverBuildTime = utils
                .statFile(paths.serverPath)
                ?.mtime.getTime()
            result.dataEditorBuildTime = utils
                .statFile(paths.dataEditorIndexPath)
                ?.mtime.getTime()
            result.scriptVersion = utils.getScriptVersion()
            result.userCount = dbtools.TableInfo(userDB, 'users').dataCount

            const questionDbCount = getQuestionDbs().length
            const { subjCount, questionCount } = countOfQdbs(getQuestionDbs())
            result.qdbInfo = {
                questionDbCount: questionDbCount,
                subjectCount: subjCount,
                questionCount: questionCount,
            }
        }

        return result
    }

    async function syncData({
        shouldSync,
        allTime,
    }: {
        shouldSync: {
            questions: boolean
            users: boolean
            userFiles: boolean
        }
        allTime: boolean
    }) {
        if (peers.length === 0) {
            logger.Log(
                `There are no peers specified in ${paths.peersFile}, aborting sync`,
                'yellowbg'
            )
            return {
                msg: 'No peers specified, aborting',
            }
        }
        // FIXME: this might be blocking the main thread, but not sure how much
        logger.Log(
            `\tStarting data sync, getting new data from ${logger.C('green')}${
                peers.length
            }${logger.C()} peers`
        )

        const syncAll =
            !shouldSync ||
            Object.values(shouldSync).filter((x) => x).length === 0

        logger.Log(
            `\tSyncing: ${
                syncAll
                    ? 'everything'
                    : Object.entries(shouldSync)
                          // eslint-disable-next-line @typescript-eslint/no-unused-vars
                          .filter(([_key, value]) => value)
                          .map(([key]) => key)
                          .join(', ')
            }`,
            'green'
        )

        if (allTime) {
            logger.Log(`\tSyncing since all time!`, 'yellowbg')
        }

        const lastSync = selfInfo.lastSync
        logger.Log(
            `\tLast sync date: ${logger.C('blue')}${
                lastSync ? new Date(lastSync).toLocaleString() : 'never'
            }${logger.C()}`
        )
        const syncStart = new Date().getTime()
        const lastSyncInfos = peers.map((peer) => {
            return [
                peerToString(peer),
                peer.lastSync
                    ? new Date(peer.lastSync).toLocaleString()
                    : 'never',
            ]
        })
        logger.Log(`\tLast sync with peers:`)
        logger.logTable([['', 'Date'], ...lastSyncInfos], {
            colWidth: [20],
            rowPrefix: '\t',
        })

        const requests = peers.map((peer) => {
            return authAndGetNewData({
                peer: peer,
                selfInfo: selfInfo,
                allTime: allTime,
                shouldSync: shouldSync,
            })
        })

        const allResults = await Promise.all(requests)

        // -------------------------------------------------------------------------------------------------------
        // filtering, transforming, and counting responses
        // -------------------------------------------------------------------------------------------------------
        allResults.forEach((res) => {
            if (res?.error) {
                logger.Log(
                    `\tError syncing with ${peerToString(res.peer)}: ${
                        res.error.message
                    }`,
                    'red'
                )
            }
        })

        const resultDataWithoutErrors = allResults.filter(
            (resData) => !resData.error && resData.data
        )

        if (resultDataWithoutErrors.length === 0) {
            logger.Log(
                `No peers returned data without error, aborting sync`,
                'redbg'
            )
            return {
                msg: 'No peers returned data without error, aborting sync',
            }
        }

        // -------------------------------------------------------------------------------------------------------
        // third party peers handling
        // -------------------------------------------------------------------------------------------------------
        const peersHosts = [...peers, selfInfo]
        const thirdPartyPeers = resultDataWithoutErrors
            .map((res) => res.data.remoteInfo)
            .flatMap((res) => res.myPeers)
            .filter((res) => {
                return !peersHosts.some((localPeer) => {
                    return isPeerSameAs(localPeer, res)
                })
            })

        if (thirdPartyPeers.length > 0) {
            updateThirdPartyPeers(thirdPartyPeers)
            logger.Log(
                `\tPeers reported ${logger.C('green')}${
                    thirdPartyPeers.length
                }${logger.C()} third party peer(s) not connected to this server. See ${logger.C(
                    'blue'
                )}${paths.thirdPartyPeersFile}${logger.C()} for details`
            )
        }

        // -------------------------------------------------------------------------------------------------------
        // data syncing
        // -------------------------------------------------------------------------------------------------------

        const getData = <T extends keyof Omit<SyncDataResult, 'remoteInfo'>>(
            key: T
        ) => {
            let data = resultDataWithoutErrors.map((x) => ({
                ...x.data[key],
                peer: x.peer,
            }))

            data.forEach((x) => {
                if (!x.success) {
                    logger.Log(
                        `Error syncing "${key}" with ${peerToString(
                            x.peer
                        )}: "${x.message}"`,
                        'yellowbg'
                    )
                }
            })

            if ((!data || data.length === 0) && (shouldSync[key] || syncAll)) {
                logger.Log(
                    `"${key}" data was requested, but not received!`,
                    'yellowbg'
                )
            }

            data = data.filter((x) => x.success)

            return data
        }

        const syncResults: SyncResult[] = []

        const questionData = getData('questions')
        if (questionData && questionData.length > 0) {
            const res = await syncQuestions({
                questionData: questionData,
                syncStart: syncStart,
                getQuestionDbs: getQuestionDbs,
                setQuestionDbs: setQuestionDbs,
                selfInfo: selfInfo,
                dbsFile: dbsFile,
            })
            syncResults.push(res)
        }

        const userData = getData('users')
        if (userData && userData.length > 0) {
            const res = await syncUsers({
                userData: userData,
                syncStart: syncStart,
                userDB: userDB,
                privateKey: privateKey,
            })
            syncResults.push(res)
        }

        const userFilesData = getData('userFiles')
        if (userFilesData && userFilesData.length > 0) {
            const res = await syncUserFiles(userFilesData, syncStart)
            syncResults.push(res)
        }

        logger.Log('Sync finished', 'green')

        return syncResults.reduce(
            (acc, x) => {
                return {
                    old: { ...acc.old, ...x.old },
                    added: { ...acc.added, ...x.added },
                    final: { ...acc.final, ...x.final },
                }
            },
            { old: {}, added: {}, final: {} }
        )
    }

    // ---------------------------------------------------------------------------------------
    // APP SETUP
    // ---------------------------------------------------------------------------------------
    app.get('/selfInfo', (_req: Request, res: Response<PeerInfo>) => {
        res.json({ ...selfInfo, publicKey: publicKey })
    })

    app.get('/p2pinfo', (_req: Request, res: Response<RemotePeerInfo>) => {
        res.json(getSelfInfo(true))
    })

    app.get('/getnewdata', (req: Request, res: Response<any>) => {
        logger.LogReq(req)
        const questionsSince = Number.isNaN(+req.query.questionsSince)
            ? 0
            : +req.query.questionsSince
        const usersSince = Number.isNaN(+req.query.usersSince)
            ? 0
            : +req.query.usersSince
        const userFilesSince = Number.isNaN(+req.query.userFilesSince)
            ? 0
            : +req.query.userFilesSince

        const questions = !!req.query.questions
        const users = !!req.query.users
        const userFiles = !!req.query.userFiles
        const remoteHost = req.query.host

        const sendAll = !questions && !users && !userFiles

        let hostToLog = remoteHost || 'Unknown host'
        let remotePeerInfo: PeerInfo = null

        if (remoteHost) {
            remotePeerInfo = peers.find((peer) => {
                return peerToString(peer) === remoteHost
            })
            if (!remotePeerInfo) {
                handleNewThirdPartyPeer(remoteHost)
            } else {
                hostToLog = peerToString(remotePeerInfo)
            }
        }

        const result: SyncDataResult = {
            remoteInfo: getSelfInfo(),
        }

        if (questions || sendAll) {
            result.questions = getQuestions(questionsSince, getQuestionDbs)

            const questionsSinceDate = questionsSince
                ? new Date(questionsSince).toLocaleString()
                : 'all time'

            logger.Log(
                `\tSending new data to ${logger.C(
                    'blue'
                )}${hostToLog}${logger.C()} since ${logger.C(
                    'blue'
                )}${questionsSinceDate}${logger.C()}`
            )
            logger.logTable(
                [
                    ['', 'QDBs', 'Subjs', 'Questions'],
                    [
                        'Count',
                        result.questions.questionDbs.length,
                        result.questions.count.subjects,
                        result.questions.count.questions,
                    ],
                ],
                { rowPrefix: '\t' }
            )
        }

        if (users || sendAll) {
            result.users = getUsers(
                remoteHost,
                remotePeerInfo,
                usersSince,
                userDB
            )

            if (result.users.success) {
                const usersSinceDate = usersSince
                    ? new Date(usersSince).toLocaleString()
                    : 'all time'

                logger.Log(
                    `\tSending new users to ${logger.C(
                        'blue'
                    )}${hostToLog}${logger.C()} since ${logger.C(
                        'blue'
                    )}${usersSinceDate}${logger.C()}. Sent users: ${logger.C(
                        'blue'
                    )}${result.users.sentUsers}${logger.C()}`
                )
            } else {
                logger.Log(result.users.message, 'yellowbg')
            }
        }

        if (userFiles || sendAll) {
            const newUserFilesResult = getUserFiles(userFilesSince)
            const sentFilesCount = Object.values(
                newUserFilesResult.newFiles
            ).reduce((acc, data) => {
                return acc + Object.keys(data).length
            }, 0)
            result.userFiles = newUserFilesResult

            const userFilesSinceDate = questionsSince
                ? new Date(questionsSince).toLocaleString()
                : 'all time'

            logger.Log(
                `\tSending new user files to ${logger.C(
                    'blue'
                )}${hostToLog}${logger.C()} since ${logger.C(
                    'blue'
                )}${userFilesSinceDate}${logger.C()} Sent files: ${logger.C(
                    'blue'
                )}${sentFilesCount}${logger.C()}`
            )
        }

        res.json(result)
    })

    app.get('/syncp2pdata', (req: Request, res: Response) => {
        logger.LogReq(req)
        const questions = !!req.query.questions
        const users = !!req.query.users
        const userFiles = !!req.query.userFiles

        const allTime = !!req.query.allTime
        const user = req.session.user

        if (!user || user.id !== 1) {
            res.json({
                status: 'error',
                message: 'only user 1 can call this EP',
            })
            return
        }

        // FIXME: /syncResult EP if this EP times out, but we still need the result
        if (syncInProgress) {
            res.json({
                error: 'A sync is already in progress!',
            })
            return
        }

        syncInProgress = true
        setPendingJobsAlertCount(5000)
        syncData({
            shouldSync: {
                questions: questions,
                users: users,
                userFiles: userFiles,
            },
            allTime: allTime,
        })
            .then((syncResult) => {
                res.json({
                    msg: 'sync successfull',
                    ...syncResult,
                })
                setPendingJobsAlertCount()
                syncInProgress = false
            })
            .catch((e) => {
                console.error(e)
                res.json({
                    error: e,
                    msg: e.message,
                })
                setPendingJobsAlertCount()
                syncInProgress = false
            })
    })

    app.post(
        '/newusercreated',
        (req: Request<{ host: string; newUsers: string }>, res: Response) => {
            logger.LogReq(req)

            const encryptedNewUsers = req.body.newUsers
            const host = req.body.host

            const result = handleNewUsers({
                encryptedNewUsers: encryptedNewUsers,
                host: host,
                peers: peers,
                privateKey: privateKey,
                userDB: userDB,
            })

            res.json(result)
        }
    )

    app.post(
        '/newuserfilecreated',
        async (req: Request<NewUserFilesRequestBody>, res: Response) => {
            logger.LogReq(req)

            const newFiles = req.body.newFiles
            const remoteHost = req.body.host

            const result = await handleNewUserFiles({
                newFiles: newFiles,
                host: remoteHost,
                peers: peers,
            })

            res.json(result)
        }
    )

    logger.Log(
        'P2P functionality set up. Peers (' +
            peers.length +
            '): ' +
            peers.map((peer) => peerToString(peer)).join(', '),
        'blue'
    )

    return {}
}

export default {
    setup: setup,
}
