"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Script = void 0;
const crypto_1 = __importDefault(require("crypto"));
const events_1 = require("events");
const jsonpointer_1 = __importDefault(require("jsonpointer"));
const uuid_1 = require("uuid");
const config_js_1 = require("../../config.js");
const exception_js_1 = require("../../exception.js");
const logger_js_1 = require("../../logger.js");
const context_match_timer_js_1 = require("../context-match-timer.js");
const index_1 = require("../services/index");
const util = __importStar(require("../util/index.js"));
const action_js_1 = require("./action.js");
const context_js_1 = require("./context.js");
const definition_js_1 = require("./definition.js");
const entity_js_1 = require("./entity.js");
const inspection_js_1 = require("./inspection.js");
const search_js_1 = require("./search.js");
const CONTEXT_MATCH_INTERVAL = (0, config_js_1.numberConfig)('CONTEXT_MATCH_INTERVAL', 100);
const CONTEXT_MATCH_SLOW_MARGIN = (0, config_js_1.numberConfig)('CONTEXT_MATCH_SLOW_MARGIN', 3000);
const CONTEXT_MATCH_BATCH_SIZE = (0, config_js_1.numberConfig)('CONTEXT_MATCH_BATCH_SIZE', 30);
class Script extends entity_js_1.Entity {
    static async load($engine, spec = {}) {
        const resolver = $engine.get(index_1.ResolverService);
        const registry = $engine.get(index_1.RegistryService);
        const unmet = [...resolver.unmetDependencies(spec.dependencies || [])];
        const loadPromises = unmet.map(async (dep) => {
            const ext = await registry.loadExtension(dep.name, dep.version);
            resolver.addExtension(ext);
        });
        await Promise.all(loadPromises);
        return new Script($engine, spec);
    }
    constructor($engine, spec = {}) {
        super(null);
        this.$events = new events_1.EventEmitter();
        this.dependencies = [];
        this.$ids = new Map();
        this.$inputs = [];
        this.$outputs = [];
        this.$consumedInputKeys = new Set();
        this.$engine = $engine;
        const { id = (0, uuid_1.v4)(), contexts = [], blockedUrlPatterns = [], dependencies = [] } = spec;
        this.id = id;
        this.contexts = new context_js_1.ContextList(this, 'contexts', contexts);
        this.dependencies = dependencies;
        this.blockedUrlPatterns = blockedUrlPatterns;
        this.applyContextConstraints();
        this.reset();
    }
    toJSON() {
        this.dependencies = [...this.collectUsedDependencies()].map(dep => {
            return { name: dep.name, version: '^' + dep.version };
        });
        return super.toJSON();
    }
    get $entityType() {
        return 'script';
    }
    get $key() {
        return '';
    }
    get $browser() { return this.$engine.get(index_1.BrowserService); }
    get $page() { return this.$browser.page; }
    get $logger() { return this.$engine.get(logger_js_1.Logger); }
    get $flow() { return this.$engine.get(index_1.FlowService); }
    get $config() { return this.$engine.get(config_js_1.Configuration); }
    get $globals() { return this.$engine.get(index_1.GlobalsService); }
    get $reporter() { return this.$engine.get(index_1.ReporterService); }
    reset() {
        this.$inputs = [];
        this.$outputs = [];
        this.$playback = {
            paused: false,
            running: false,
            mode: 'action',
            status: 'idle',
            error: null,
            playhead: null,
            lastContext: null,
        };
    }
    registerId(id, object) {
        this.$ids.set(id, object);
    }
    unregisterId(id) {
        this.$ids.delete(id);
    }
    hashInputOutputKey(key) {
        const i = key.indexOf(':');
        if (i > -1) {
            const prefix = key.substring(0, i);
            const suffix = key.substring(i + 1);
            const hash = crypto_1.default
                .createHash('sha1')
                .update(suffix, 'utf8')
                .digest('hex');
            return `${prefix}:${hash}`;
        }
        return key;
    }
    async requestInput(key) {
        key = this.hashInputOutputKey(key);
        if (!this.$consumedInputKeys.has(key)) {
            this.$consumedInputKeys.add(key);
            this.$events.emit('input', { key });
        }
        const existing = this.$flow.isInputsCached() ? this.$inputs.find(d => d.key === key) : null;
        if (!existing) {
            const data = await this.$flow.requestInputData(key);
            this.$inputs.push({ key, data });
            return data;
        }
        return existing.data;
    }
    async peekInput(key) {
        key = this.hashInputOutputKey(key);
        return await this.$flow.peekInputData(key);
    }
    async resetInput(key) {
        key = this.hashInputOutputKey(key);
        await this.$flow.resetInputData(key);
        this.$inputs = this.$inputs.filter(_ => _.key !== key);
        this.$consumedInputKeys.delete(key);
    }
    async sendOutput(key, data) {
        key = this.hashInputOutputKey(key);
        this.$outputs.push({ key, data });
        await this.$flow.sendOutputData(key, data);
        this.$events.emit('output', { key, data });
    }
    wasOutputEmitted(key) {
        return this.$outputs.some(_ => _.key === key);
    }
    async waitForOutputs(...keys) {
        return new Promise((resolve, reject) => {
            const onOutput = () => {
                const outputs = keys.map(key => this.$outputs.find(_ => _.key === key)).filter(Boolean);
                if (outputs.length === keys.length) {
                    cleanup();
                    resolve(outputs.map(_ => _?.data));
                }
            };
            const onSuccess = () => {
                cleanup();
                reject(new exception_js_1.Exception({
                    name: 'MissingOutputs',
                    message: `Script succeded, but specified outputs were not emitted`,
                    details: { keys },
                }));
            };
            const onFail = () => {
                cleanup();
                reject(new exception_js_1.Exception({
                    name: 'MissingOutputs',
                    message: `Script failed, and specified outputs were not emitted`,
                    details: { keys },
                }));
            };
            const cleanup = () => {
                this.$events.removeListener('output', onOutput);
                this.$events.removeListener('success', onSuccess);
                this.$events.removeListener('fail', onFail);
            };
            this.$events.addListener('output', onOutput);
            this.$events.addListener('success', onSuccess);
            this.$events.addListener('fail', onFail);
            onOutput();
        });
    }
    getGlobalKeys() {
        return this.$globals.getKeys();
    }
    getGlobal(key, optional = false) {
        return this.$globals.getGlobal(key, optional);
    }
    setGlobal(key, value) {
        return this.$globals.setGlobal(key, value);
    }
    appendGlobal(key, values) {
        return this.$globals.appendGlobal(key, values);
    }
    removeGlobal(key) {
        return this.$globals.removeGlobal(key);
    }
    get(path) {
        return jsonpointer_1.default.get(this, path);
    }
    applyContextConstraints() {
        const hasMainContext = this.contexts.find(_ => _.type === 'main');
        if (!hasMainContext) {
            this.contexts.insert({
                type: 'main',
                name: '<main>',
            }, 0);
        }
        const checkpointCtx = this.getCheckpointContext();
        if (checkpointCtx && checkpointCtx.$index !== 1) {
            this.contexts.move(checkpointCtx, 1);
        }
    }
    getMainContext() {
        const context = this.contexts.find(ctx => ctx.type === 'main');
        util.assertScript(context, 'No main context');
        return context;
    }
    getCheckpointContext() {
        return this.contexts.find(_ => _.type === 'checkpoint') || null;
    }
    getContextById(id) {
        return this.contexts.find(ctx => ctx.id === id) || null;
    }
    getFirstAction() {
        const mainContext = this.getMainContext();
        return mainContext.children.first;
    }
    getActionById(id) {
        const obj = this.$ids.get(id);
        return obj instanceof action_js_1.Action ? obj : null;
    }
    getDefinitionById(id) {
        const obj = this.$ids.get(id);
        return obj instanceof definition_js_1.DefinitionAction ? obj : null;
    }
    requireDefinition(id) {
        const def = this.getDefinitionById(id);
        util.assertScript(def != null, 'Definition not found');
        return def;
    }
    *allActions() {
        for (const context of this.contexts) {
            yield* context.descendentActions();
        }
    }
    setPlayhead(action) {
        this.$playback.playhead = action;
    }
    setStatus(status) {
        this.$playback.status = status;
        this.$events.emit('statusUpdated', status);
    }
    isRunning() {
        return this.$playback.running;
    }
    isPaused() {
        return this.$playback.paused || !this.$playback.running;
    }
    pause() {
        this.$playback.mode = 'action';
        this.$playback.paused = true;
    }
    async tick() {
        await this.$flow.tick(this);
    }
    async beforeRun() {
        await this.$page.send('Network.setBlockedURLs', {
            urls: this.blockedUrlPatterns,
        });
        await this.$page.send('Network.setBypassServiceWorker', {
            bypass: true,
        });
        await this.executeOnScriptRunHandlers();
    }
    async run(mode = 'script', action = this.$playback.playhead) {
        util.assertPlayback(!this.isRunning(), 'Cannot run: already running');
        this.$playback.paused = false;
        this.$playback.running = true;
        this.$playback.mode = mode;
        this.$playback.playhead = action;
        this.$playback.error = null;
        try {
            await this.beforeRun();
            while (!this.isPaused()) {
                await this._performCurrentTask();
            }
            this.$events.emit('success');
        }
        catch (error) {
            this.$playback.error = error;
            this.$playback.status = 'error';
            this.$events.emit('fail', error);
            throw error;
        }
        finally {
            this.$playback.running = false;
            this.$playback.mode = 'action';
            this.$playback.paused = false;
            this.$events.emit('done');
        }
    }
    async step() {
        await this.run('action');
    }
    async runAll() {
        await this.run('script', this.getFirstAction());
    }
    *inspect() {
        const resolver = this.$engine.$resolver;
        const inspections = resolver.getInspectionClasses().map(cl => new cl(this));
        const scriptInspections = inspections.filter(_ => _ instanceof inspection_js_1.ScriptInspection);
        const contextInspections = inspections.filter(_ => _ instanceof inspection_js_1.ContextInspection);
        const actionInspections = inspections.filter(_ => _ instanceof inspection_js_1.ActionInspection);
        for (const insp of scriptInspections) {
            yield* this.collectReports(insp, this);
        }
        if (!contextInspections.length && !actionInspections.length) {
            return;
        }
        for (const context of this.contexts) {
            for (const insp of contextInspections) {
                yield* this.collectReports(insp, context);
            }
            if (!actionInspections.length) {
                continue;
            }
            for (const action of context.descendentActions()) {
                for (const insp of actionInspections) {
                    yield* this.collectReports(insp, action);
                }
            }
        }
    }
    *collectReports(inspection, node) {
        for (const res of inspection.inspect(node)) {
            yield {
                action: node instanceof action_js_1.Action ? node : undefined,
                context: node instanceof context_js_1.Context ? node :
                    node instanceof action_js_1.Action ? node.$context : undefined,
                ...res
            };
        }
    }
    async _performCurrentTask() {
        await this.tick();
        this.$events.emit('beforeCurrentTask');
        if (this.isPaused()) {
            return;
        }
        const action = this.$playback.playhead;
        if (action) {
            this.$playback.lastContext = action.$context;
            await action.run();
        }
        else {
            await this._enterNextContext();
        }
        this.$playback.running = this._shouldContinue();
    }
    async _enterNextContext() {
        const context = await this.matchNextContext();
        context.enter();
        this.$playback.lastContext = context;
        await this.executeOnContextEnterHandlers(context);
        const label = context.name;
        await this.$reporter.sendScreenshot('debug', { label });
        await this.$reporter.sendHtmlSnapshot('debug');
    }
    _shouldContinue() {
        if (this.$playback.paused) {
            return false;
        }
        switch (this.$playback.mode) {
            case 'action':
                return false;
            case 'context':
                return !!this.$playback.playhead;
            case 'script':
                return this.$playback.status !== 'success';
            default:
                return false;
        }
    }
    async matchNextContext() {
        try {
            this.$playback.status = 'matching';
            for (const ctx of this.contexts) {
                ctx.resetMatchers();
            }
            const ctx = await this._matchNextContext();
            return ctx;
        }
        finally {
            this.$playback.status = 'idle';
        }
    }
    async _matchNextContext() {
        const contextMatchSlowMargin = this.$config.get(CONTEXT_MATCH_SLOW_MARGIN);
        const contextMatchInterval = this.$config.get(CONTEXT_MATCH_INTERVAL);
        const { lastContext } = this.$playback;
        const isSameCtx = (ctx) => lastContext && lastContext.id === ctx.id;
        const isSlowMatch = (ctx) => isSameCtx(ctx) || ctx.matchMode === 'slow';
        const timer = new context_match_timer_js_1.ContextMatchTimer(this.$config, this.$page);
        let lastStableMatchAt = Date.now();
        let lastIds = '';
        while (!timer.checkExpired()) {
            await this.tick();
            await this.$page.waitForReady({
                rejectNetworkErrors: true,
                rejectHttpErrors: false,
                rejectTimeout: false,
                timeout: 5000,
            });
            await new Promise(r => setTimeout(r, contextMatchInterval));
            const matchedContexts = await this.matchContexts(true);
            const newIds = matchedContexts
                .map(ctx => ctx.id)
                .sort((a, b) => (a > b ? 1 : -1))
                .join(',');
            if (lastIds !== newIds) {
                lastIds = newIds;
                lastStableMatchAt = Date.now();
            }
            const isStable = Date.now() > lastStableMatchAt + contextMatchSlowMargin;
            if (matchedContexts.length === 0) {
                continue;
            }
            const matchSlowly = matchedContexts.some(ctx => isSlowMatch(ctx));
            if (matchSlowly && !isStable) {
                continue;
            }
            return matchedContexts[0];
        }
        throw await this.resolveContextMatchTimeoutError();
    }
    async matchContexts(filterByLimit = true) {
        const candidates = this.contexts.filter(context => {
            return context.type === 'context' && (!filterByLimit || !context.isLimitReached());
        });
        const matchResults = await this.matchContextInBatches(candidates);
        const matchedContexts = [];
        for (const [i, ctx] of candidates.entries()) {
            if (matchResults[i]) {
                matchedContexts.push(ctx);
            }
        }
        return matchedContexts;
    }
    async matchContextInBatches(candidates) {
        const batchSize = this.$config.get(CONTEXT_MATCH_BATCH_SIZE);
        const results = [];
        const remaining = candidates.slice();
        while (remaining.length > 0) {
            const batch = remaining.slice(0, batchSize);
            remaining.splice(0, batch.length);
            const res = await Promise.all(batch.map(context => context.runMatch()));
            results.push(...res);
        }
        return results;
    }
    async resolveContextMatchTimeoutError() {
        const matchedContexts = await this.matchContexts(false);
        if (matchedContexts.length > 0) {
            const errorCode = matchedContexts.map(ctx => ctx.errorCode).find(Boolean);
            const message = `Context limit exceeded (${errorCode || 'no error code'})`;
            return util.createError({
                code: errorCode ?? 'ContextLimitError',
                message,
                retry: false,
                scriptError: errorCode != null,
                details: {
                    contexts: matchedContexts.map(ctx => {
                        return {
                            id: ctx.id,
                            name: ctx.name,
                        };
                    }),
                },
            });
        }
        return util.createError({
            code: 'NoContextsMatch',
            message: 'No contexts match',
            retry: false,
        });
    }
    search(queries, options = {}) {
        return new search_js_1.ScriptSearch(this).search(queries, options);
    }
    *collectInputKeys() {
        for (const action of this.allActions()) {
            yield* action.collectInputKeys();
        }
    }
    *collectOutputKeys() {
        for (const action of this.allActions()) {
            yield* action.collectOutputKeys();
        }
    }
    *collectUsedDependencies() {
        const depNames = new Set(['<core>']);
        for (const dep of this.collectAllUsedDependencies()) {
            if (!depNames.has(dep.name)) {
                depNames.add(dep.name);
                yield dep;
            }
        }
    }
    *collectAllUsedDependencies() {
        for (const action of this.allActions()) {
            const dep = action.$class.$extension;
            if (dep) {
                yield {
                    name: dep.spec.name,
                    version: dep.spec.version,
                };
            }
            for (const pipe of action.descendentPipes()) {
                const dep = pipe.$class.$extension;
                if (dep) {
                    yield {
                        name: dep.spec.name,
                        version: dep.spec.version,
                    };
                }
            }
        }
    }
    async executeOnScriptRunHandlers() {
        const promises = this.$engine.resolveSessionHandlers().map(async (service) => {
            try {
                if (service.onScriptRun) {
                    await service.onScriptRun(this);
                }
            }
            catch (error) {
                this.$logger.error('onScriptRun handler failed', {
                    serviceName: service.constructor.name,
                    error,
                });
            }
        });
        await Promise.all(promises);
    }
    async executeOnContextEnterHandlers(context) {
        const promises = this.$engine.resolveSessionHandlers().map(async (service) => {
            try {
                if (service.onContextEnter) {
                    await service.onContextEnter(context);
                }
            }
            catch (error) {
                this.$logger.error('onContextEnter handler failed', {
                    serviceName: service.constructor.name,
                    error,
                });
            }
        });
        await Promise.all(promises);
    }
}
exports.Script = Script;
//# sourceMappingURL=script.js.map