// Copyright (C) 2019-2021 Apple Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
// 1. Redistributions of source code must retain the above copyright
//    notice, this list of conditions and the following disclaimer.
// 2. Redistributions in binary form must reproduce the above copyright
//    notice, this list of conditions and the following disclaimer in the
//    documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
// BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
// THE POSSIBILITY OF SUCH DAMAGE.

import {ErrorDisplay, escapeHTML, linkify, paramsToQuery, queryToParams} from '/assets/js/common.js';

const TIMESTAMP_TO_UUID_MULTIPLIER = 100;

function CommitTable(commits, repositoryIds = [], oneLine = false) {
    var rows = []
    if (!commits || !commits.length)
        return ErrorDisplay({error: 'No Commits Found', description: 'Cannot display the commit table, no commits found.'});
    var sorted_commits = commits.sort((a, b) => {
        return (b.timestamp * TIMESTAMP_TO_UUID_MULTIPLIER + b.order) - (a.timestamp * TIMESTAMP_TO_UUID_MULTIPLIER + a.order);
    });

    var repositories_with_commits = []
    if (!repositoryIds || !repositoryIds.length) {
        repositoryIds = [];
        commits.forEach((commit) => {
            if (repositoryIds.indexOf(commit.repository_id) < 0)
                repositoryIds.push(commit.repository_id);
        });
        repositoryIds.sort();
    }

    var lastValidCellForIndex = [0];
    var rows = [[{rowspan: 1, timestamp: sorted_commits[0].timestamp}]];

    for (const repository of repositoryIds) {
        for (let index = 0; index < sorted_commits.length; ++index) {
            if (sorted_commits[index].repository_id === repository) {
                repositories_with_commits.push(repository);

                lastValidCellForIndex.push(0);
                rows[0].push({rowspan: 1, commit: sorted_commits[index]});
                sorted_commits.splice(index, 1);
                break;
            }
        }
    }

    for (const commit of sorted_commits) {
        let repo_index = repositories_with_commits.indexOf(commit.repository_id);
        if (repo_index < 0)
            continue;
        if (!rows[rows.length - 1][repo_index + 1] && commit.timestamp === rows[lastValidCellForIndex[0]][0].timestamp) {
            rows[lastValidCellForIndex[repo_index + 1]][repo_index + 1].rowspan -= 1;
            rows[rows.length - 1][repo_index + 1] = {rowspan: 1, commit: commit};
            lastValidCellForIndex[repo_index + 1] = rows.length - 1;
            continue;
        }

        rows.push(new Array(repositories_with_commits.length + 1).fill(null));
        if (commit.timestamp === rows[lastValidCellForIndex[0]][0].timestamp)
            rows[lastValidCellForIndex[0]][0].rowspan += 1
        else {
            rows[rows.length - 1][0] = {rowspan: 1, timestamp: commit.timestamp};
            lastValidCellForIndex[0] = rows.length - 1;
        }
        rows[rows.length - 1][repo_index + 1] = {rowspan: 1, commit: commit};
        lastValidCellForIndex[repo_index + 1] = rows.length - 1;
        for (let index = 0; index < repositories_with_commits.length; ++index) {
            if (index != repo_index)
                rows[lastValidCellForIndex[index + 1]][index + 1].rowspan += 1;
        }
    }

    return `<table class="table full-width commit-table">
        <thead>
            <th></th>
            ${repositories_with_commits.map(id => `<th>${id}</th>`).join('')}
        </thead>
        <tbody>
            ${rows.map(row =>`<tr>
                ${row.map(function(cell) {
                    if (!cell)
                        return '';
                    if (cell.timestamp)
                        return `<td rowspan="${cell.rowspan}">${new Date(cell.timestamp * 1000).toLocaleString()}</td>`;

                    let commitArgs = paramsToQuery({
                        repository_id: [cell.commit.repository_id],
                        branch: [cell.commit.branch],
                        ref: [cell.commit.label()],
                    });
                    let investigateArgs = {ref: [cell.commit.label()]};
                    if (!['master', 'main', 'trunk'].includes(cell.commit.branch))
                        investigateArgs.branch = [cell.commit.branch];

                    return `<td rowspan="${cell.rowspan}">
                        <a href="/commit?${commitArgs}">${cell.commit.label()}</a> <br>
                        ${cell.commit.label().includes('@') ? '' : `Branch: ${cell.commit.branch} <br>`}
                        ${cell.commit.author ? `Author: ${escapeHTML(cell.commit.author.name)}
                            &#60<a href="mailto:${escapeHTML(cell.commit.author.emails[0])}">${escapeHTML(cell.commit.author.emails[0])}</a>&#62 <br>` : ''}
                        <a href="/commit/info?${commitArgs}">More Info</a><br>
                        <a href="/investigate?${paramsToQuery(investigateArgs)}">Test results for commit</a>
                        ${function() {
                            if (!cell.commit.message)
                                return '';
                            if (oneLine)
                                return `<br><div>${linkify(escapeHTML(cell.commit.message.split('\n')[0]))}</dv>`;
                            return `<br><div>${linkify(escapeHTML(cell.commit.message))}</div>`;
                        }()}
                    </td>`;
                }).join('')}
            </tr>`).join('')}
        </tbody>
    </table>`;
}

class Commit {
    constructor(json) {
        this.identifier = json.identifier ? json.identifier : json.id;
        this.revision = json.revision ? json.revision : null;
        this.hash = json.hash ? json.hash : null;

        this.author = null;
        if (json.author && json.author.name && json.author.emails && json.author.emails[0])
            this.author = json.author

        this.repository_id = json.repository_id;
        this.branch = json.branch;
        this.message = json.message;

        //FIXME: Need to create a more general solution for tracking bugs  
        this.bugUrls = json.message.match(/\b(https?):\/{2}[^\s<>&]+[^\.\s<>&,)]/gmi);
        this.radarUrls = json.message.match(/\b(rdar):\/{2}[^\s<>&]+[^\.\s<>&,)]/gmi);

        this.timestamp = json.timestamp;
        this.order = json.order;
        this.uuid = this.timestamp * TIMESTAMP_TO_UUID_MULTIPLIER + this.order;
    }
    compare(commit) {
        return this.uuid - commit.uuid;
    }
    label() {
        const preference = CommitBank._representationCache ? CommitBank._representationCache[this.repository_id].representation : null;

        if (preference === 'identifier' && this.identifier)
            return this.identifier;
        if (preference === 'hash' && this.hash) {
            // Per the birthday paradox, 10% chance of collision with 7.7 million commits with 12 character commits
            return this.hash.substring(0,12)
        }
        if (preference === 'revision' && this.revision)
            return `r${this.revision}`;

        return this.revision ? `r${this.revision}` : this.hash.substring(0,12);
    }
};

const COOKIE_NAME = 'commitRepresentation';
let _banks = {};

class _CommitBank {
    forBranch(branch) {
        let key = branch ? branch : 'main';
        if (!_banks[key]) {
            _banks[key] = new _CommitBank();
            _banks[key]._branches = new Set([branch]);
            _banks[key]._branchOverride = true;
        }
        return _banks[key];
    }
    constructor() {
        this.commits = [];

        const params = queryToParams(document.URL.split('?')[1]);

        this._branchOverride = false;
        this._branches = new Set(params.branch);
        this._repositories = new Set(params.repository_id);
        this._beginUuid = null;
        this._endUuid = null;
        this.callbacks = [];
        this._representationCache = null;
    }
    commitRepresentations(callback) {
        if (this._representationCache != null) {
            callback(this._representationCache)
            return;
        }
        const cookies = decodeURIComponent(document.cookie).split(';')
        for (let index = 0; index < cookies.length; ++index) {
            if (cookies[index].indexOf(`${COOKIE_NAME}=`))
                continue;

            this._representationCache = JSON.parse(cookies[index].substring(COOKIE_NAME.length + 1));
            if (!this._representationCache.error) {
                callback(this._representationCache);
                return;
            }
            this._representationCache = null;
        }

        fetch('api/commits/representations').then(response => {
            response.json().then(json => {
                this._representationCache = {};
                Object.keys(json).forEach(repository => {
                    if (!json[repository].length)
                        return;
                    this._representationCache[repository] = {
                        'representation': json[repository][0],
                        'candidates': json[repository],
                    }
                });
                document.cookie = `${COOKIE_NAME}=${JSON.stringify(this._representationCache)}`;
                callback(this._representationCache);
            });
        }).catch(error => {
            // If the load fails, log the error and continue
            console.error(JSON.stringify(error, null, 4));
        });
    }
    setCommitRepresentation(repository, representation) {
        this._representationCache[repository].representation = representation;
        document.cookie = `${COOKIE_NAME}=${JSON.stringify(this._representationCache)}`;
    }
    latest(params) {
        if (!this._branchOverride)
            this._branches = new Set(params.branch);
        this._repositories = new Set(params.repository_id);
        this._beginUuid = null;
        this._endUuid = null;

        // Only provide the latest commits
        const self = this;
        return new Promise(function(resolve, reject) {
            fetch(Object.keys(params).length ? `api/commits?${paramsToQuery(params)}` : 'api/commits').then(response => {
                response.json().then(json => {
                    self._beginUuid = Math.floor(Date.now() / 1000);
                    self._endUuid = 0;

                    const oldestIdForRepo = {};
                    const candidates = [];
                    json.forEach(datum =>{
                        const commit = new Commit(datum);
                        candidates.push(commit);

                        if (oldestIdForRepo[commit.repository_id])
                            oldestIdForRepo[commit.repository_id] = Math.min(commit.uuid, oldestIdForRepo[commit.repository_id]);
                        else
                            oldestIdForRepo[commit.repository_id] = commit.uuid;
                    });

                    let oldestValidUuid = 0;
                    Object.keys(oldestIdForRepo).forEach(id => {
                        oldestValidUuid = Math.max(oldestValidUuid, id);
                    });

                    candidates.forEach(commit => {
                        if (commit.uuid <= oldestValidUuid)
                            return;
                        self.commits.push(commit);
                        self._beginUuid = Math.min(commit.uuid, self._beginUuid);
                        self._endUuid = Math.max(commit.uuid, self._endUuid);
                    });

                    if (self._beginUuid > self._endUuid) {
                        self._beginUuid = null;
                        self._endUuid = null;
                    }

                    resolve(this);
                });
            }).catch(error => {
                // If the load fails, log the error and continue
                console.error(JSON.stringify(error, null, 4));
                reject(error);
            });
        });
    }
    commitByUuid(uuid) {
        let begin = 0;
        let end = this.commits.length - 1;
        while (begin <= end) {
            const mid = Math.ceil((begin + end) / 2);
            const candidate = this.commits[mid];
            if (candidate.uuid === uuid)
                return candidate;
            if (candidate.uuid < uuid)
                begin = mid + 1;
            else
                end = mid - 1;
        }
        return null;
    }
    commitsDuring(minUuid, maxUuid = 0) {
        let commits = [];
        let begin = 0;
        let end = this.commits.length - 1;
        let counter = 0;
        let index = this.commits.length - 1;

        if (maxUuid == 0)
            maxUuid = minUuid;
        if (maxUuid < minUuid) {
            console.error('Invalid uuid range');
            return commits;
        }

        while (begin <= end) {
            counter = Math.ceil((begin + end) / 2);
            const candidate = this.commits[counter];
            if (minUuid <= candidate.uuid && candidate.uuid <= maxUuid) {
                index = counter - 1;
                break;
            }

            if (candidate.uuid < minUuid)
                begin = counter + 1;
            else
                end = counter - 1;
        }
        while (counter < this.commits.length) {
            const candidate = this.commits[counter];
            if (minUuid > candidate.uuid || candidate.uuid > maxUuid)
                break
            commits.push(candidate);
            ++counter;
        }

        let repositories = new Set();
        if (commits.length)
            repositories.add(commits[0].repository_id);

        while (index >= 0) {
            if (repositories.has(this.commits[index].repository_id)) {
                --index;
                continue;
            }
            if (this._repositories.length && !this._repositories.has(this.commits[index].repository_id)) {
                --index;
                continue;
            }

            commits.unshift(this.commits[index]);
            repositories.add(this.commits[index].repository_id);
            if (repositories.length == this._repositories.length)
                break;

            --index;
        }
        return commits;
    }
    _loadSiblings(commit) {
        const query = paramsToQuery({
            branch: [commit.branch],
            repository_id: [commit.repository_id],
            ref: [commit.hash ? commit.hash : commit.revision],
        });
        return fetch('api/commits/siblings?' + query).then(response => {
            let self = this;
            const originalLength = self.commits.length;
            response.json().then(json => {
                Object.keys(json).forEach(key => {
                    if (this._repositories.size > 0 && !this._repositories.has(key))
                        return;

                    // We get sibiling commits backwards
                    let index = self.commits.length - 1;
                    json[key].forEach(commitJson => {
                        const commit = new Commit(commitJson);
                        while (index >= 0) {
                            if (self.commits[index].uuid < commit.uuid) {
                                self.commits.splice(index, 0, commit);
                                --index;
                                break;
                            }
                            if (self.commits[index].uuid === commit.uuid)
                                break;
                            --index;
                        }
                        if (index < 0)
                            self.commits.splice(0, 0, commit);
                    });
                });

                if (originalLength != self.commits.length)
                    self.callbacks.forEach(callback => {
                        callback();
                    });
            });
        });
    }
    _load(beginUuid, endUuid) {
        if (endUuid < beginUuid)
            return;

        const limit = 2500;
        const query = paramsToQuery({
            branch: [...this._branches],
            limit: [limit],
            repository_id: [...this._repositories],
            after_uuid: [beginUuid],
            before_uuid: [endUuid]
        });

        return fetch(query ? 'api/commits?' + query : 'api/commits').then(response => {
            let self = this;
            response.json().then(json => {
                // We should have gotten a list of commits, if not, log the result and continue.
                if (!Array.isArray(json)) {
                    console.error(JSON.stringify(json, null, 4));
                    return;
                }

                let originalLength = self.commits.length;
                let commitsIndex = 0;
                let countForRepository = new Map();
                let firstIndexForRepository = new Map();
                for (let index = 0; index < json.length; ++index) {
                    const commit = new Commit(json[index]);
                    if (!firstIndexForRepository.has(commit.repository_id))
                        firstIndexForRepository.set(commit.repository_id, index);
                    if (!countForRepository.has(commit.repository_id))
                        countForRepository.set(commit.repository_id, 1);
                    else
                        countForRepository.set(commit.repository_id, countForRepository.get(commit.repository_id) + 1);

                    while (commitsIndex < self.commits.length) {
                        if (self.commits[commitsIndex].uuid > commit.uuid) {
                            self.commits.splice(commitsIndex, 0, commit);
                            ++commitsIndex;
                            break;
                        }
                        if (self.commits[commitsIndex].uuid === commit.uuid)
                            break;
                        ++commitsIndex;
                    }
                    if (commitsIndex === self.commits.length) {
                        self.commits.push(commit);
                        ++commitsIndex;
                        continue;
                    }
                }

                let minFound = beginUuid;
                countForRepository.forEach((count, repo) => {
                    if (count === limit) {
                        let commit = new Commit(json[firstIndexForRepository.get(repo)]);
                        minFound = Math.max(minFound, commit.uuid);
                    }
                });
                if (minFound != beginUuid)
                    self._load(beginUuid, minFound);
                else
                    self._loadSiblings(new Commit(json[0]));

                if (originalLength != self.commits.length)
                    self.callbacks.forEach(callback => {
                        callback();
                    });
            });
        }).catch(error => {
            // If the load fails, log the error and continue
            console.error(JSON.stringify(error, null, 4));
        });
    }
    add(beginUuid, endUuid) {
        if (!this._beginUuid && !this._endUuid) {
            this._beginUuid = beginUuid;
            this._endUuid = endUuid;
            return this._load(beginUuid, endUuid);
        }

        let promises = [];
        if (this._beginUuid && beginUuid < this._beginUuid)
            promises.push(this._load(beginUuid, this._beginUuid));
        if (this._endUuid && endUuid > this._endUuid)
            promises.push(this._load(this._endUuid, endUuid));
        return Promise.all(promises);
    }
    addCommit(ref) {
        const query = paramsToQuery({
            branch: [...this._branches],
            ref: [ref],
        });

        return fetch(`api/commits?${query}`).then(response => {
            let self = this;
            response.json().then(json => {
                for (const commit of json) {
                    const uuid = commit.timestamp * 100 + commit.order;
                    self.add(uuid, uuid);
                }
            });
        }).catch(error => {
            // If the load fails, log the error and continue
            console.error(JSON.stringify(error, null, 4));
        });
    }
    reload() {
        let needReload = false;
        const params = queryToParams(document.URL.split('?')[1]);

        function equal(a, b) {
            if (a.size !== b.size)
                return false;
            for (let elm of a) {
                if (!b.has(elm))
                    return false;
            }
            return true;
        }

        const branchSet = new Set(params.branch);
        if (!equal(branchSet, this._branches)) {
            this._branches = branchSet;
            needReload = true;
        }
        const repoSet = new Set(params.repository_id);
        if (!equal(repoSet, this._repositories)) {
            this._repositories = repoSet;
            needReload = true;
        }

        if (needReload) {
            this.commits = [];
            return this._load(this._beginUuid, this._endUuid);
        }
    }
}

const CommitBank = new _CommitBank();

export {Commit, CommitBank, CommitTable, TIMESTAMP_TO_UUID_MULTIPLIER};
