import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import { ExecutionContext } from './context.js';
import { DSLExecutor } from '../dslExecutor/executor.js';
import { Combinator } from './combinator.js';
import { Logger } from '../utils/logger.js';
import { MongoManager } from '../db/mongoClient.js';
import { SelectorResolver } from './selectorResolver.js';
import { VariableResolver } from '../utils/variableResolver.js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { TestRegistry } from './registry.js';
import { ConfigManager } from '../utils/configManager.js';
import { BrowserManager } from './browserManager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
 * Orchestrates the execution of a Scenario.
 */
export class Runner {
    static mongoManager = null;
    /**
     * Runs a scenario from a configuration file or object.
     */
    static async runScenario(scenarioPathOrConfig, organizationId = 'LOCAL_USER', jobEmitter, overrides) {
        const runId = overrides?.runId || uuidv4();
        const registry = await TestRegistry.getInstance();
        let scenario;
        if (typeof scenarioPathOrConfig === 'string') {
            scenario = await registry.getScenario(scenarioPathOrConfig, organizationId);
        }
        else {
            scenario = scenarioPathOrConfig;
        }
        if (overrides) {
            scenario = { ...scenario, ...overrides };
        }
        // INITIALIZATION PHASE
        let flows = [];
        let inputs;
        let expected;
        let cases = [];
        let batchId = runId;
        try {
            // 1. LOAD FLOWS
            flows = await Promise.all(scenario.flows.map(async (f) => {
                if (typeof f === 'string') {
                    return await registry.getFlow(f, organizationId);
                }
                return f; // Already a flow object
            }));
            // 2. INITIALIZE SELECTOR REGISTRY
            await SelectorResolver.init();
            // 3. INIT DATABASE
            await this.initDatabase(scenario);
            // 4. Initialize inputs (Priority: DB inputData > File inputFile)
            inputs = scenario.inputData;
            if (!inputs && scenario.inputFile) {
                inputs = this.loadData(scenario.inputFile);
            }
            // 5. Merge Shared Data Sets
            const refs = scenario.sharedDataRefs || [];
            for (const ref of refs) {
                try {
                    const shared = await registry.getSharedData(ref, organizationId);
                    if (shared && shared.data) {
                        if (Array.isArray(shared.data)) {
                            inputs = shared.data;
                        }
                        else {
                            if (Array.isArray(inputs)) {
                                inputs = inputs.map(i => ({ ...shared.data, ...i }));
                            }
                            else if (inputs) {
                                inputs = { ...shared.data, ...inputs };
                            }
                            else {
                                inputs = shared.data;
                            }
                        }
                    }
                }
                catch (e) {
                    Logger.warn(`Failed to load shared data reference: ${ref}`, e);
                }
            }
            expected = this.loadData(scenario.expectedFile);
            cases = this.generateTestCases(scenario, inputs, expected);
            if (scenario.isPreview && cases.length > 1) {
                Logger.log(`[Runner] 🛠️ Preview mode active. Truncating combinatorial expansion from ${cases.length} cases down to 1 for rapid verification.`);
                cases = cases.slice(0, 1);
            }
            // If a runId is provided (e.g. from the worker), use it as the batch anchor
            batchId = runId || uuidv4();
        }
        catch (initError) {
            Logger.error(`Scenario initialization failed for [${scenario.name || scenarioPathOrConfig}]`, initError);
            this.emitEvent(jobEmitter, 'batch:failed', {
                error: initError.message,
                scenarioName: scenario.name
            });
            throw initError;
        }
        const concurrency = Math.max(1, scenario.concurrency || 1);
        this.emitEvent(jobEmitter, 'batch:started', { batchId, total: cases.length });
        Logger.log(`Starting Scenario: ${scenario.name || 'Unnamed'} | Mode: ${scenario.mode} | Cases: ${cases.length} | BatchID: ${batchId}`);
        let completedCases = 0;
        const updateBatchProgress = async () => {
            const progress = Math.round((completedCases / cases.length) * 100);
            this.emitEvent(jobEmitter, 'batch:progress', {
                batchId,
                progress,
                completed: completedCases,
                total: cases.length
            });
            // Persist progress to the batch record for refresh resilience
            const registry = await TestRegistry.getInstance();
            const mongo = await registry.getMongo();
            if (mongo) {
                await mongo.upsertRun('job_history', batchId, {
                    progress,
                    metadata: {
                        completed: completedCases,
                        total: cases.length
                    }
                });
            }
        };
        let isStopped = false;
        let anyFailed = false;
        let firstError = '';
        const activeContexts = new Set();
        if (jobEmitter) {
            jobEmitter.on('stop', () => {
                isStopped = true;
                activeContexts.forEach(ctx => ctx.isStopped = true);
                Logger.warn(`Stop signal received for Batch [${batchId}]`);
            });
        }
        for (let i = 0; i < cases.length; i += concurrency) {
            if (isStopped) {
                Logger.log(`Gracefully halting Batch [${batchId}]`);
                break;
            }
            const chunk = cases.slice(i, i + concurrency);
            await Promise.all(chunk.map(async (testCase) => {
                if (isStopped)
                    return;
                const currentRunId = uuidv4();
                const context = new ExecutionContext(scenario);
                activeContexts.add(context);
                context.inputs = testCase.inputs;
                context.expected = testCase.expected;
                context.runId = currentRunId;
                context.batchId = batchId;
                context.organizationId = organizationId;
                context.metadata = testCase.metadata || {};
                context.aiConfig = scenario.aiConfig;
                // Load Org Settings for CAPTCHA/AI keys
                const registry = await TestRegistry.getInstance();
                context.settings = await registry.getOrganizationSettings(organizationId).catch(() => ({}));
                context.onItemExtracted = async (item) => {
                    this.emitEvent(jobEmitter, 'job:data', { runId: currentRunId, data: item });
                };
                context.onProgress = async (step, index) => {
                    if (!context.page)
                        return;
                    const screenshot = await context.page.screenshot({ fullPage: true, type: 'jpeg', quality: 50 }).catch(() => null);
                    const progressData = {
                        stepIndex: index,
                        currentStep: step,
                        screenshot: screenshot ? screenshot.toString('base64') : undefined,
                        timestamp: new Date(),
                        // Pass through existing loop iteration metadata if we are inside a buffered/loop context
                        metadata: { ...(context.extractionBuffer || {}) }
                    };
                    this.emitEvent(jobEmitter, 'job:progress', {
                        runId: currentRunId,
                        ...progressData
                    });
                    // PERSIST to MongoDB so it shows in Historical Logs
                    const registry = await TestRegistry.getInstance();
                    await registry.saveRunLog(currentRunId, batchId, {
                        level: 'info',
                        message: `[Step ${index + 1}] Executed ${step.action}`,
                        timestamp: new Date(),
                        metadata: {
                            screenshot: progressData.screenshot,
                            loop_iteration: context.extractionBuffer?.loop_iteration
                        }
                    }, organizationId).catch(() => { });
                };
                const logHandler = async (data) => {
                    // If the log belongs to a specific run, only process it if it's OUR run.
                    // If it's a global log (no runId), we allow it but tag it with our runId for the UI.
                    if (data.runId && data.runId !== currentRunId)
                        return;
                    this.emitEvent(jobEmitter, 'job:log', { runId: currentRunId, ...data });
                    const registry = await TestRegistry.getInstance();
                    await registry.saveRunLog(currentRunId, batchId, data, organizationId).catch(() => { });
                };
                Logger.emitter.on('log', logHandler);
                this.emitEvent(jobEmitter, 'job:started', {
                    runId: currentRunId,
                    batchId,
                    scenarioName: scenario.name,
                    doc_type: scenario.doc_type,
                    inputs: context.inputs,
                    metadata: context.metadata
                });
                try {
                    await Logger.storage.run({ runId: currentRunId, batchId }, async () => {
                        await this.executeCase(context, flows, jobEmitter);
                    });
                    const screenshot = await this.captureAndSaveScreenshot(context, jobEmitter);
                    this.emitEvent(jobEmitter, 'job:completed', {
                        runId: currentRunId,
                        inputs: context.inputs,
                        metadata: context.metadata,
                        inputData: context.inputs, // Redundant but helpful for some UI views
                        runMetadata: context.metadata,
                        lastScreenshot: screenshot
                    });
                    completedCases++;
                    await updateBatchProgress();
                }
                catch (error) {
                    Logger.error(`Case execution failed [${currentRunId}]`, error);
                    // CAPTURE ERROR STATE (Defensive against closed browser)
                    try {
                        if (context.page && !context.page.isClosed()) {
                            const screenshotDir = path.resolve(__dirname, '../output/screenshots');
                            if (!fs.existsSync(screenshotDir))
                                fs.mkdirSync(screenshotDir, { recursive: true });
                            const screenshotPath = path.join(screenshotDir, `failure_${currentRunId}.png`);
                            await context.page.screenshot({ path: screenshotPath, fullPage: true });
                            Logger.warn(`Failure screenshot captured: ${screenshotPath}`);
                        }
                    }
                    catch (screenshotErr) {
                        Logger.warn(`Could not capture failure screenshot: ${screenshotErr.message}`);
                    }
                    const screenshot = await this.captureAndSaveScreenshot(context, jobEmitter);
                    anyFailed = true;
                    firstError = error.message;
                    this.emitEvent(jobEmitter, 'job:failed', {
                        runId: currentRunId,
                        batchId,
                        error: error.message,
                        stack: error.stack,
                        inputs: context.inputs,
                        metadata: context.metadata,
                        inputData: context.inputs,
                        runMetadata: context.metadata,
                        lastScreenshot: screenshot
                    });
                }
                finally {
                    activeContexts.delete(context);
                    Logger.emitter.removeListener('log', logHandler);
                    await context.cleanup();
                    BrowserManager.getInstance().decrementActiveJobs();
                }
            }));
        }
        if (anyFailed) {
            this.emitEvent(jobEmitter, 'batch:failed', { batchId, error: firstError });
        }
        else {
            this.emitEvent(jobEmitter, 'batch:completed', { batchId });
        }
        Logger.log('Scenario execution completed.');
    }
    static async captureAndSaveScreenshot(context, jobEmitter) {
        try {
            if (!context.page || context.page.isClosed())
                return null;
            const screenshotBase64 = await context.page.screenshot({
                type: 'jpeg',
                quality: 60,
                fullPage: false // Only viewport for preview efficiency
            }).then(buf => buf.toString('base64'));
            if (this.mongoManager) {
                await this.mongoManager.saveScreenshot(context.runId, context.batchId, screenshotBase64);
            }
            this.emitEvent(jobEmitter, 'job:screenshot', {
                runId: context.runId,
                screenshot: screenshotBase64
            });
            return screenshotBase64;
        }
        catch (err) {
            Logger.warn(`Failed to capture/save screenshot for run ${context.runId}: ${err.message}`);
            return null;
        }
    }
    static emitEvent(emitter, event, data) {
        if (emitter && event)
            emitter.emit(event, data);
    }
    static async initDatabase(scenario) {
        const configManager = ConfigManager.getInstance();
        const globalDbConfig = configManager.getMongoConfig();
        const dbConfig = {
            ...globalDbConfig,
            enabled: scenario.database?.enabled ?? globalDbConfig.enabled,
            collection: scenario.database?.collection ?? globalDbConfig.collection
        };
        if (dbConfig.enabled) {
            this.mongoManager = await MongoManager.getInstance(dbConfig);
            await this.mongoManager.connect();
        }
    }
    static async executeCase(context, flows, jobEmitter) {
        const { scenario } = context;
        const isHeadless = scenario.headless ?? true;
        const browserManager = BrowserManager.getInstance();
        context.browserContext = await browserManager.getNewContext({
            headless: isHeadless,
            proxy: scenario.proxy
        });
        context.page = await context.browserContext.newPage();
        // Set default timeouts
        context.page.setDefaultTimeout(30000);
        context.page.setDefaultNavigationTimeout(30000);
        // Apply resource blocking and protection
        await browserManager.setupResourceBlocking(context.page);
        browserManager.setupPageProtection(context.browserContext, context.page);
        // INITIAL NAVIGATION
        const resolvedUrl = VariableResolver.resolve(scenario.baseUrl, context);
        Logger.log(`Navigating to entry point: ${resolvedUrl}`);
        await context.page.goto(resolvedUrl, { waitUntil: 'networkidle' });
        // ATOMIC PERSISTENCE HOOK
        context.onItemExtracted = async (item) => {
            const record = this.createRecord(context, item);
            const registry = await TestRegistry.getInstance();
            const collection = context.targetCollection || 'results';
            Logger.log(`[Data Extracted] Run: ${context.runId} | Collection: ${collection}\n${JSON.stringify(item, null, 2)}`);
            await registry.persist('results', collection, record);
            context.results.push(record);
            this.saveResultsLocally(context);
        };
        // EXECUTE COMPOSED FLOWS
        for (const flow of flows) {
            if (context.isStopped)
                break;
            Logger.log(`Executing Flow: ${flow.name}`);
            // Inject AI Config into Executor for this flow
            if (scenario.aiConfig) {
                DSLExecutor.setAIConfig(scenario.aiConfig);
            }
            await DSLExecutor.executeFlow(context, flow.steps, async (step, index) => {
                if (context.onProgress)
                    await context.onProgress(step, index);
                this.emitEvent(jobEmitter, 'job:step', {
                    runId: context.runId,
                    step,
                    index,
                    total: flow.steps.length,
                    flowName: flow.name
                });
            });
        }
        if (scenario.mode === 'scrape') {
            const recordsToSave = [];
            if (context.extractedItems.length > 0) {
                // Check if items were already saved via onItemExtracted to avoid duplicate DB calls here
                // But results collection should still be populated for local JSON output
                for (const item of context.extractedItems) {
                    const record = this.createRecord(context, item);
                    // Only save if not already in results (meaning it was scraped via a non-atomic step)
                    const alreadySaved = context.results.some(r => r.doc_hash === record.doc_hash);
                    if (!alreadySaved) {
                        if (this.mongoManager)
                            await this.mongoManager.save(record);
                        context.results.push(record);
                        this.saveResultsLocally(context);
                    }
                }
            }
            else {
                // SINGLE-DOCUMENT MODE: Generic store capture
                const storeData = Object.fromEntries(context.store);
                const record = this.createRecord(context, storeData);
                if (this.mongoManager)
                    await this.mongoManager.save(record);
                context.results.push(record);
                this.saveResultsLocally(context);
            }
        }
        if (scenario.mode === 'validate') {
            await DSLExecutor.validate(context);
        }
    }
    /**
     * Constructs a standardized record with doc_type and doc_hash.
     */
    static createRecord(context, item) {
        // Strip loop_iteration and system metadata from the item for hash calculation
        // to ensure deduplication is based ONLY on the actual content found.
        const { loop_iteration, ...cleanItem } = item;
        const hash = crypto.createHash('sha256').update(JSON.stringify(cleanItem)).digest('hex');
        return {
            organizationId: context.organizationId,
            doc_type: context.scenario.doc_type || 'generic',
            doc_hash: hash,
            metadata: context.metadata,
            inputs: context.inputs,
            data: item,
            timestamp: new Date(),
            runId: context.runId,
            batchId: context.batchId
        };
    }
    static loadData(fileName) {
        if (!fileName)
            return undefined;
        const filePath = path.resolve(__dirname, '../data', fileName);
        if (!fs.existsSync(filePath))
            return undefined;
        return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
    }
    static generateTestCases(scenario, inputs, expected) {
        // 1. Priority 1: Standard Array-based Inputs (Data Files)
        if (Array.isArray(inputs)) {
            return inputs.map((input, index) => ({
                inputs: { ...scenario.inputData, ...input }, // Merge with base scenario inputs
                expected: expected ? expected[index] : undefined,
                metadata: { index }
            }));
        }
        // 2. Priority 2: Combinatorial Filters (Search-based Scraping)
        let filterSource = scenario.filters;
        if (!filterSource && inputs && typeof inputs === 'object') {
            filterSource = inputs;
        }
        if (scenario.mode === 'scrape' && filterSource && Object.keys(filterSource).length > 0) {
            const combinations = Combinator.generateCombinations(filterSource);
            if (combinations.length > 0) {
                return combinations.map(c => ({ inputs: c, metadata: c }));
            }
        }
        // 3. Fallback: Single run with available filters or empty
        return [{ inputs: filterSource || {} }];
    }
    static saveResultsLocally(context) {
        const outDir = ConfigManager.getInstance().getResultsBaseDir();
        if (!fs.existsSync(outDir))
            fs.mkdirSync(outDir, { recursive: true });
        const filePath = path.join(outDir, `results_${context.runId}.json`);
        // Append the last result to the local file
        fs.appendFileSync(filePath, JSON.stringify(context.results[context.results.length - 1], null, 2) + ',\n');
    }
}
