/*******************************************************************************

    uBlock Origin - a browser extension to block requests.
    Copyright (C) 2014-present Raymond Hill

    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 {http://www.gnu.org/licenses/}.

    Home: https://github.com/gorhill/uBlock
*/

'use strict';

// This module can be dynamically loaded or spun off as a worker.

/******************************************************************************/

const patches = new Map();
const encoder = new TextEncoder();
const reFileName = /([^\/]+?)(?:#.+)?$/;
const EMPTYLINE = '';

/******************************************************************************/

const suffleArray = arr => {
    const out = arr.slice();
    for ( let i = 0, n = out.length; i < n; i++ ) {
        const j = Math.floor(Math.random() * n);
        if ( j === i ) { continue; }
        [ out[j], out[i] ] = [ out[i], out[j] ];
    }
    return out;
};

const basename = url => {
    const match = reFileName.exec(url);
    return match && match[1] || '';
};

const resolveURL = (path, url) => {
    try {
        return new URL(path, url);
    }
    catch(_) {
    }
};

const expectedTimeFromPatch = assetDetails => {
    const match = /(\d+)\.(\d+)\.(\d+)\.(\d+)/.exec(assetDetails.patchPath);
    if ( match === null ) { return 0; }
    const date = new Date();
    date.setUTCFullYear(
        parseInt(match[1], 10),
        parseInt(match[2], 10) - 1,
        parseInt(match[3], 10)
    );
    date.setUTCHours(0, parseInt(match[4], 10), 0, 0);
    return date.getTime() + assetDetails.diffExpires;
};

function parsePatch(patch) {
    const patchDetails = new Map();
    const diffLines = patch.split('\n');
    let i = 0, n = diffLines.length;
    while ( i < n ) {
        const line = diffLines[i++];
        if ( line.startsWith('diff ') === false ) { continue; }
        const fields = line.split(/\s+/);
        const diffBlock = {};
        for ( let j = 0; j < fields.length; j++ ) {
            const field = fields[j];
            const pos = field.indexOf(':');
            if ( pos === -1 ) { continue; }
            const name = field.slice(0, pos);
            if ( name === '' ) { continue; }
            const value = field.slice(pos+1);
            switch ( name ) {
            case 'name':
            case 'checksum':
                diffBlock[name] = value;
                break;
            case 'lines':
                diffBlock.lines = parseInt(value, 10);
                break;
            default:
                break;
            }
        }
        if ( diffBlock.name === undefined ) { return; }
        if ( isNaN(diffBlock.lines) || diffBlock.lines <= 0 ) { return; }
        if ( diffBlock.checksum === undefined ) { return; }
        patchDetails.set(diffBlock.name, diffBlock);
        diffBlock.diff = diffLines.slice(i, i + diffBlock.lines).join('\n');
        i += diffBlock.lines;
    }
    if ( patchDetails.size === 0 ) { return; }
    return patchDetails;
}

function applyPatch(text, diff) {
    // Inspired from (Perl) "sub _patch" at:
    // https://twiki.org/p/pub/Codev/RcsLite/RcsLite.pm
    // Apparently authored by John Talintyre in Jan. 2002
    // https://twiki.org/cgi-bin/view/Codev/RcsLite
    const lines = text.split('\n');
    const diffLines = diff.split('\n');
    let iAdjust = 0;
    let iDiff = 0, nDiff = diffLines.length;
    while ( iDiff < nDiff ) {
        const diffParsed = /^([ad])(\d+) (\d+)$/.exec(diffLines[iDiff++]);
        if ( diffParsed === null ) { return; }
        const op = diffParsed[1];
        const iOp = parseInt(diffParsed[2], 10);
        const nOp = parseInt(diffParsed[3], 10);
        const iOpAdj = iOp + iAdjust;
        if ( iOpAdj > lines.length ) { return; }
        // Delete lines
        if ( op === 'd' ) {
            lines.splice(iOpAdj-1, nOp);
            iAdjust -= nOp;
            continue;
        }
        // Add lines: Don't use splice() to avoid stack limit issues
        for ( let i = 0; i < nOp; i++ ) {
            lines.push(EMPTYLINE);
        }
        lines.copyWithin(iOpAdj+nOp, iOpAdj);
        for ( let i = 0; i < nOp; i++ ) {
            lines[iOpAdj+i] = diffLines[iDiff+i];
        }
        iAdjust += nOp;
        iDiff += nOp;
    }
    return lines.join('\n');
}

function hasPatchDetails(assetDetails) {
    const { patchPath } = assetDetails;
    const patchFile = basename(patchPath);
    return patchFile !== '' && patches.has(patchFile);
}

/******************************************************************************/

// Async

async function applyPatchAndValidate(assetDetails, diffDetails) {
    const { text } = assetDetails;
    const { diff, checksum } = diffDetails;
    const textAfter = applyPatch(text, diff);
    if ( typeof textAfter !== 'string' ) {
        assetDetails.error = 'baddiff';
        return false;
    }
    const crypto = globalThis.crypto;
    if ( typeof crypto !== 'object' ) {
        assetDetails.error = 'nocrypto';
        return false;
    }
    const arrayin = encoder.encode(textAfter);
    const arraybuffer = await crypto.subtle.digest('SHA-1', arrayin);
    const arrayout = new Uint8Array(arraybuffer);
    const sha1Full = Array.from(arrayout).map(i =>
        i.toString(16).padStart(2, '0')
    ).join('');
    if ( sha1Full.startsWith(checksum) === false ) {
        assetDetails.error = 'badchecksum';
        return false;
    }
    assetDetails.text = textAfter;
    const head = textAfter.slice(0, 1024);
    let match = /^! Diff-Path: (\S+)/m.exec(head);
    assetDetails.patchPath = match ? match[1] : undefined;
    match = /^! Diff-Name: (\S+)/m.exec(head);
    assetDetails.diffName = match ? match[1] : undefined;
    return true;
}

async function fetchPatchDetailsFromCDNs(assetDetails) {
    const { patchPath, cdnURLs } = assetDetails;
    if ( Array.isArray(cdnURLs) === false ) { return null; }
    if ( cdnURLs.length === 0 ) { return null; }
    for ( const cdnURL of suffleArray(cdnURLs) ) {
        const patchURL = resolveURL(patchPath, cdnURL);
        if ( patchURL === undefined ) { continue; }
        const response = await fetch(patchURL).catch(reason => {
            console.error(reason);
        });
        if ( response === undefined ) { continue; }
        if ( response.ok !== true ) { continue; }
        const patchText = await response.text();
        const patchDetails = parsePatch(patchText);
        if ( patchURL.hash.length > 1 ) {
            assetDetails.diffName = patchURL.hash.slice(1);
            patchURL.hash = '';
        }
        return {
            patchURL: patchURL.href,
            patchSize: `${(patchText.length / 1000).toFixed(1)} KB`,
            patchDetails,
        };
    }
    return null;
}

async function fetchPatchDetails(assetDetails) {
    const { patchPath } = assetDetails;
    const patchFile = basename(patchPath);
    if ( patchFile === '' ) { return null; }
    if ( patches.has(patchFile) ) {
        return patches.get(patchFile);
    }
    const patchDetailsPromise = fetchPatchDetailsFromCDNs(assetDetails);
    patches.set(patchFile, patchDetailsPromise);
    return patchDetailsPromise;
}

async function fetchAndApplyAllPatches(assetDetails) {
    if ( assetDetails.fetch === false ) {
        if ( hasPatchDetails(assetDetails) === false ) {
            assetDetails.status = 'nodiff';
            return assetDetails;
        }
    }
    // uBO-specific, to avoid pointless fetches which are likely to fail
    // because the patch has not yet been created
    const patchTime = expectedTimeFromPatch(assetDetails);
    if ( patchTime > Date.now() ) {
        assetDetails.status = 'nopatch-yet';
        return assetDetails;
    }
    const patchData = await fetchPatchDetails(assetDetails);
    if ( patchData === null ) {
        assetDetails.status = (Date.now() - patchTime) < (4 * assetDetails.diffExpires)
            ? 'nopatch-yet'
            : 'nopatch';
        return assetDetails;
    }
    const { patchDetails } = patchData;
    if ( patchDetails instanceof Map === false ) {
        assetDetails.status = 'nodiff';
        return assetDetails;
    }
    const diffDetails = patchDetails.get(assetDetails.diffName);
    if ( diffDetails === undefined ) {
        assetDetails.status = 'nodiff';
        return assetDetails;
    }
    if ( assetDetails.text === undefined ) {
        assetDetails.status = 'needtext';
        return assetDetails;
    }
    const outcome = await applyPatchAndValidate(assetDetails, diffDetails);
    if ( outcome !== true ) { return assetDetails; }
    assetDetails.status = 'updated';
    assetDetails.patchURL = patchData.patchURL;
    assetDetails.patchSize = patchData.patchSize;
    return assetDetails;
}

/******************************************************************************/

const bc = new globalThis.BroadcastChannel('diffUpdater');

bc.onmessage = ev => {
    const message = ev.data;
    switch ( message.what ) {
    case 'update':
        fetchAndApplyAllPatches(message).then(response => {
            bc.postMessage(response);
        }).catch(error => {
            bc.postMessage({ what: 'broken', error });
        });
        break;
    }
};

bc.postMessage({ what: 'ready' });

/******************************************************************************/
