/**
 * Egress-Policy Live Research Test  (NOT for CI)
 * ================================================
 *
 * End-to-end run against a real local LLM (Ollama) and a real local
 * search engine (SearXNG) hosted on a known LAN box. This is the
 * lab-truth complement to test_egress_policy_ui.js:
 *
 *   - configures Ollama + SearXNG endpoints
 *   - kicks off a real research run under each scope and verifies the
 *     run actually starts (or correctly refuses, in the STRICT +
 *     non-primary-engine case)
 *   - exercises the require-local-LLM toggle by trying to use a cloud
 *     provider through the form
 *   - captures screenshots of each run state so the UX can be eyeballed
 *
 * Why NO_CI_:  the CI runner has neither Ollama nor SearXNG. The
 * tests/ui_tests/run_all_tests.js orchestrator skips files matching
 * /^NO_CI_/ so this script never runs there.  See sibling
 * NO_CI_executes_research_*.js for the same convention.
 *
 * Endpoints (override via env if your lab box changes IP):
 *   OLLAMA_URL    default http://192.168.178.66:11434
 *   SEARXNG_URL   default http://192.168.178.66:8081
 *   OLLAMA_MODEL  default llama3.2:1b
 *
 * Usage (local dev):
 *   node tests/ui_tests/NO_CI_test_egress_policy_live_research.js
 *   HEADLESS=false node tests/ui_tests/NO_CI_test_egress_policy_live_research.js
 */

const fs = require('fs');
const path = require('path');
const http = require('http');

const puppeteer = require('puppeteer');
const AuthHelper = require('./auth_helper');
const { getPuppeteerLaunchOptions } = require('./puppeteer_config');

const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:5000';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://192.168.178.66:11434';
const SEARXNG_URL = process.env.SEARXNG_URL || 'http://192.168.178.66:8080';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.6:latest';
const IS_CI = !!process.env.CI;

const SCREENSHOT_DIR = path.join(
    __dirname,
    'screenshots',
    'egress-policy-live',
);

const results = [];
function record(label, ok, detail) {
    results.push({ label, ok, detail });
    const icon = ok ? '✅' : '❌';
    console.log(`  ${icon} ${label}${detail ? ` — ${detail}` : ''}`);
}

function ensureDir(dir) {
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}

async function screenshot(page, name) {
    if (IS_CI) return;
    ensureDir(SCREENSHOT_DIR);
    const file = path.join(SCREENSHOT_DIR, `${name}.png`);
    await page.screenshot({ path: file, fullPage: true });
    console.log(`     📸 ${path.relative(process.cwd(), file)}`);
}

async function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
}

/**
 * Reachability check — if Ollama / SearXNG are off-LAN today we want
 * to skip with a clear message instead of producing a confusing
 * Puppeteer error 8 minutes into the run.
 */
async function ping(url, label, timeoutMs = 5000) {
    return new Promise((resolve) => {
        const req = http.get(url, { timeout: timeoutMs }, (res) => {
            const ok = res.statusCode >= 200 && res.statusCode < 500;
            res.resume();
            resolve(ok);
        });
        req.on('error', () => resolve(false));
        req.on('timeout', () => {
            req.destroy();
            resolve(false);
        });
    }).then((ok) => {
        record(`reachable:${label}`, ok, `${url}`);
        return ok;
    });
}

/**
 * Save a single setting via the live-save endpoint. The UI components
 * call this the same way on change events so it exercises the same
 * code path.
 */
async function saveSetting(page, key, value) {
    // Endpoint contract: PUT /settings/api/<dotted.key> with body
    // {"value": ...}. CSRF token is in a meta tag in base.html.
    return page.evaluate(
        async (k, v, base) => {
            const csrfMeta = document.querySelector('meta[name="csrf-token"]');
            const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '';
            const resp = await fetch(
                `${base}/settings/api/${encodeURIComponent(k)}`,
                {
                    method: 'PUT',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRFToken': csrf,
                    },
                    credentials: 'same-origin',
                    body: JSON.stringify({ value: v }),
                },
            );
            return { status: resp.status, ok: resp.ok };
        },
        key,
        value,
        BASE_URL,
    );
}

async function selectScope(page, value) {
    await page.select('#policy_egress_scope', value);
    await sleep(600);
    return page.evaluate(() => document.body.dataset.scope);
}

/**
 * Submit a research run via the API. Driving the actual <form> tag is
 * brittle because the JS handler immediately window.location.assign()'s
 * to /progress/<id> on a successful submit — Puppeteer's execution
 * context is destroyed mid-evaluate. Hitting /api/start_research
 * directly exercises the same server entry point (including the
 * precheck added in research_routes.py:248) and lets us observe the
 * status code cleanly without racing the redirect.
 *
 * Payload mirrors what the form posts (see research.js handleResearchSubmit).
 */
/**
 * Terminate a running research so we don't blow through the per-user
 * concurrent-research cap (server logs show "Active research count: 3/3"
 * at the 4th run). Idempotent — silently ignores missing IDs.
 */
async function terminateResearch(page, researchId) {
    if (!researchId) return;
    await page.evaluate(
        async (id, base) => {
            const csrfMeta = document.querySelector('meta[name="csrf-token"]');
            const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '';
            try {
                await fetch(`${base}/api/terminate/${id}`, {
                    method: 'POST',
                    headers: { 'X-CSRFToken': csrf },
                    credentials: 'same-origin',
                });
            } catch { /* idempotent */ }
        },
        researchId,
        BASE_URL,
    );
}

async function startResearch(page, query, overrides = {}) {
    return page.evaluate(
        async (q, base, ov) => {
            const csrfMeta = document.querySelector('meta[name="csrf-token"]');
            const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '';
            const payload = {
                query: q,
                mode: 'quick',
                model_provider: 'ollama',
                search_engine: 'searxng',
                ...ov,
            };
            const resp = await fetch(`${base}/api/start_research`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRFToken': csrf,
                },
                credentials: 'same-origin',
                body: JSON.stringify(payload),
            });
            let body;
            try { body = await resp.json(); } catch { body = null; }
            return { status: resp.status, body };
        },
        query,
        BASE_URL,
        overrides,
    );
}

async function run(name, fn) {
    console.log(`\n— ${name}`);
    try {
        await fn();
    } catch (err) {
        record(name, false, err.message);
        console.error(`     ${err.stack || err}`);
    }
}

async function main() {
    console.log(`\n🧪 Egress-Policy LIVE Research Test`);
    console.log(`   Ollama  : ${OLLAMA_URL}  (model ${OLLAMA_MODEL})`);
    console.log(`   SearXNG : ${SEARXNG_URL}`);
    console.log('='.repeat(60));

    const ollamaUp = await ping(`${OLLAMA_URL}/api/tags`, 'ollama');
    const searxUp = await ping(SEARXNG_URL, 'searxng');
    if (!ollamaUp || !searxUp) {
        console.log(
            '\n⚠️  Endpoints not reachable. This test is intentionally local-only ' +
            '(NO_CI_) — point OLLAMA_URL / SEARXNG_URL at your lab and re-run, ' +
            'or skip if you cannot.',
        );
        process.exit(0);                       // soft-skip; runner is permissive
    }

    const browser = await puppeteer.launch(getPuppeteerLaunchOptions());
    const page = await browser.newPage();
    const authHelper = new AuthHelper(page, BASE_URL);

    page.on('pageerror', (e) => console.log(`     ⚠️  pageerror: ${e.message}`));
    page.on('console', (msg) => {
        if (msg.type() === 'error') {
            console.log(`     ⚠️  console.error: ${msg.text()}`);
        }
    });

    let exitCode = 0;
    try {
        console.log('\n🔐 Authenticate');
        await authHelper.ensureAuthenticatedWithTimeout();

        // -----------------------------------------------------------
        // Pre-flight: write Ollama / SearXNG into settings so the UI
        // form picks them up.
        // -----------------------------------------------------------
        console.log('\n📌 Configure local LLM + SearXNG');
        await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });

        await saveSetting(page, 'llm.provider', 'ollama');
        await saveSetting(page, 'llm.model', OLLAMA_MODEL);
        await saveSetting(page, 'llm.ollama.url', OLLAMA_URL);
        await saveSetting(page, 'llm.require_local_endpoint', true);
        await saveSetting(page, 'search.engine.web.searxng.instance_url', SEARXNG_URL);
        // Tie research to searxng explicitly so STRICT is allowed.
        await saveSetting(page, 'search.tool', 'searxng');
        await sleep(800);

        // Reload to ensure the form reads from the new settings.
        await page.reload({ waitUntil: 'domcontentloaded' });
        await page.waitForSelector('#policy_egress_scope', { timeout: 10000 });
        await screenshot(page, '00-form-configured');

        // -----------------------------------------------------------
        // 1. BOTH scope — research starts, scope cue is amber-light
        // -----------------------------------------------------------
        await run('1. BOTH scope: research starts successfully', async () => {
            await selectScope(page, 'both');
            await screenshot(page, '01-both-form');
            const r = await startResearch(page, 'What is the capital of France?');
            record(
                'both-submit-200',
                r.status === 200,
                `status=${r.status} body=${JSON.stringify(r.body).slice(0, 120)}`,
            );
            await terminateResearch(page, r.body && r.body.research_id);
        });

        await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
        await page.waitForSelector('#policy_egress_scope', { timeout: 10000 });

        // -----------------------------------------------------------
        // 2. PUBLIC_ONLY + SearXNG-on-private-IP → server refuses
        //    Dynamic URL classification (_classify_engine_url at
        //    egress_policy.py:362) reclassifies the engine as is_local
        //    because the configured instance URL points at 192.168.x.
        //    PUBLIC_ONLY then refuses it with scope_mismatch_public_only
        //    — exactly the right behavior. The classifier overriding
        //    the static is_public=True flag is the subtle correctness
        //    property we want this test to lock in.
        // -----------------------------------------------------------
        await run('2. PUBLIC_ONLY + local-IP SearXNG: server refuses (URL reclassifies)', async () => {
            await selectScope(page, 'public_only');
            await screenshot(page, '02-public-only-form');
            const r = await startResearch(page, 'Explain the photoelectric effect briefly.', {
                policy_egress_scope: 'public_only',
            });
            const refused =
                r.status === 400 &&
                /scope_mismatch_public_only/i.test(
                    (r.body && r.body.message) || '',
                );
            record(
                'public-only-refuses-local-ip-searxng',
                refused,
                `status=${r.status} msg=${(r.body && r.body.message || '').slice(0, 120)}`,
            );
            await screenshot(page, '02-public-only-refused');
            await terminateResearch(page, r.body && r.body.research_id);
        });

        await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
        await page.waitForSelector('#policy_egress_scope', { timeout: 10000 });

        // -----------------------------------------------------------
        // 3. PRIVATE_ONLY + SearXNG-on-private-IP → allowed
        //    Same URL-classification override, this time on the
        //    permissive side: SearXNG@192.168.x is treated as a local
        //    engine, so PRIVATE_ONLY (which forbids public egress)
        //    correctly allows it. This is the user-visible payoff of
        //    "lab-hosted SearXNG counts as private" — and ensures we
        //    haven't regressed into blocking on the static is_public
        //    flag alone.
        // -----------------------------------------------------------
        await run('3. PRIVATE_ONLY + local-IP SearXNG: allowed (URL reclassifies)', async () => {
            await selectScope(page, 'private_only');
            await screenshot(page, '03-private-only-form');
            const r = await startResearch(page, 'Test private-only with lab SearXNG.', {
                policy_egress_scope: 'private_only',
            });
            record(
                'private-only-allows-local-ip-searxng',
                r.status === 200,
                `status=${r.status} body=${JSON.stringify(r.body).slice(0, 120)}`,
            );
            await screenshot(page, '03-private-only-accepted');
            await terminateResearch(page, r.body && r.body.research_id);
        });

        await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
        await page.waitForSelector('#policy_egress_scope', { timeout: 10000 });

        // -----------------------------------------------------------
        // 4. STRICT + searxng (concrete engine) — research starts
        // -----------------------------------------------------------
        await run('4. STRICT + searxng: research starts', async () => {
            await selectScope(page, 'strict');
            await screenshot(page, '04-strict-form');
            const r = await startResearch(page, 'What is photosynthesis?', {
                policy_egress_scope: 'strict',
            });
            record(
                'strict-submit-200',
                r.status === 200,
                `status=${r.status} body=${JSON.stringify(r.body).slice(0, 120)}`,
            );
            await terminateResearch(page, r.body && r.body.research_id);
        });

        await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
        await page.waitForSelector('#policy_egress_scope', { timeout: 10000 });

        // -----------------------------------------------------------
        // 5. STRICT + non-primary engine via API — server-side guard
        //    refuses even if the UI guard is bypassed. STRICT permits
        //    only the user's primary engine (search.tool), so a direct
        //    POST naming a different engine must be rejected.
        // -----------------------------------------------------------
        await run('5. STRICT + non-primary engine via API: server-side refusal', async () => {
            // Force the back-end to see scope=strict with searxng as
            // the primary engine, then request a different engine.
            await saveSetting(page, 'search.tool', 'searxng');
            await saveSetting(page, 'policy.egress_scope', 'strict');

            // Bypass the UI guard with a direct POST mimicking the form.
            const result = await page.evaluate(
                async (base) => {
                    const csrfMeta = document.querySelector('meta[name="csrf-token"]');
                    const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '';
                    const resp = await fetch(`${base}/api/start_research`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-CSRFToken': csrf,
                        },
                        credentials: 'same-origin',
                        body: JSON.stringify({
                            query: 'strict-non-primary-incoherent',
                            mode: 'quick',
                            model_provider: 'ollama',
                            search_engine: 'wikipedia',
                            policy_egress_scope: 'strict',
                        }),
                    });
                    let body;
                    try { body = await resp.json(); } catch { body = null; }
                    return { status: resp.status, body };
                },
                BASE_URL,
            );
            // The precheck (research_routes.py) surfaces this as a
            // clean 400 with reason text (strict_not_primary).
            record(
                'strict-non-primary-refused-400',
                result.status === 400,
                `status=${result.status} msg=${(result.body && result.body.message || '').slice(0, 120)}`,
            );
            await screenshot(page, '05-strict-non-primary-api-refusal');

            // Restore.
            await saveSetting(page, 'policy.egress_scope', 'both');
        });

        // -----------------------------------------------------------
        // 6. Require-local-LLM + cloud-provider attempt
        //    The run-start precheck only validates the search engine
        //    (research_routes.py:262 evaluate_engine). LLM-endpoint
        //    enforcement happens later, at get_llm() construction time
        //    inside the strategy. So we start the run, poll the
        //    research status, and assert the policy denial surfaces as
        //    a research failure (not a precheck 400). The unit-test
        //    parametrized at tests/security/test_egress_policy.py
        //    asserts the PolicyDeniedError contract directly.
        // -----------------------------------------------------------
        await run('6. require_local_endpoint + openai → research fails with policy', async () => {
            await saveSetting(page, 'llm.require_local_endpoint', true);
            await saveSetting(page, 'llm.provider', 'openai');
            await saveSetting(page, 'llm.openai.api_key', 'sk-not-a-real-key');
            await saveSetting(page, 'llm.model', 'gpt-4o-mini');
            await sleep(500);

            const start = await startResearch(
                page,
                'cloud-llm-under-local-only',
                { model_provider: 'openai' },
            );
            const researchId = start.body && (start.body.research_id || start.body.id);
            record(
                'cloud-llm-run-accepted-at-precheck',
                start.status === 200 && !!researchId,
                `status=${start.status} research_id=${researchId}`,
            );

            // Poll the research status briefly — the strategy will try
            // to instantiate the LLM, hit the PEP, and the run will be
            // marked failed with a policy reason. We give it up to ~20s
            // before declaring the test inconclusive.
            let status = null;
            let errMsg = '';
            if (!researchId) {
                record(
                    'cloud-llm-eventually-policy-denied',
                    false,
                    'no research_id returned at precheck',
                );
                return;
            }
            for (let i = 0; i < 20; i++) {
                await sleep(1000);
                const s = await page.evaluate(
                    async (id, base) => {
                        try {
                            const r = await fetch(
                                `${base}/api/research/${id}/status`,
                                { credentials: 'same-origin' },
                            );
                            if (!r.ok) return null;
                            return r.json();
                        } catch {
                            return null;
                        }
                    },
                    researchId,
                    BASE_URL,
                );
                if (s) {
                    status = (s.status || '').toLowerCase();
                    errMsg = (s.error || s.message || '').toString();
                    if (status === 'failed' || status === 'error' || status === 'completed') {
                        break;
                    }
                }
            }
            const policyDenied =
                /policy|local|cloud|denied|require/i.test(errMsg) ||
                /no_snapshot|provider_cloud/i.test(errMsg);
            record(
                'cloud-llm-eventually-policy-denied',
                policyDenied || status === 'failed',
                `final_status=${status} err=${errMsg.slice(0, 120)}`,
            );
            await screenshot(page, '06-cloud-llm-refused');

            await terminateResearch(page, researchId);

            // Restore local config.
            await saveSetting(page, 'llm.provider', 'ollama');
            await saveSetting(page, 'llm.model', OLLAMA_MODEL);
            await saveSetting(page, 'llm.require_local_endpoint', true);
        });

        // -----------------------------------------------------------
        // 7. Done.
        // -----------------------------------------------------------
        await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
        await page.waitForSelector('#policy_egress_scope', { timeout: 10000 });
        await selectScope(page, 'both');
        await screenshot(page, '07-final-state');

    } catch (err) {
        console.error('\n❌ Suite-level error:', err.stack || err);
        exitCode = 1;
    } finally {
        console.log('\n' + '='.repeat(60));
        const passed = results.filter((r) => r.ok).length;
        const failed = results.filter((r) => !r.ok);
        console.log(`Results: ${passed} / ${results.length} passed`);
        if (failed.length) {
            console.log('Failures:');
            for (const f of failed) {
                console.log(`  ❌ ${f.label}${f.detail ? ` — ${f.detail}` : ''}`);
            }
            exitCode = 1;
        }
        if (!IS_CI) {
            console.log(`\nScreenshots: ${SCREENSHOT_DIR}`);
        }
        await browser.close();
        process.exit(exitCode);
    }
}

main().catch((err) => {
    console.error(err);
    process.exit(1);
});
