// ==UserScript==
// @name         Wellfound Job Exporter (paginated, pausable)
// @namespace    https://github.com/aliraza/ChunkOfSnippet
// @version      1.0.0
// @description  Scrape Wellfound job listings across paginated result pages and export them as CSV/JSON. Start / pause / resume / stop at any time; progress survives page reloads.
// @author       ali raza
// @match        https://wellfound.com/*
// @match        https://www.wellfound.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    /* ------------------------------------------------------------------ *
     * config / state
     * ------------------------------------------------------------------ */

    const STATE_KEY = 'wf_exporter_state_v1';

    const DEFAULT_STATE = {
        running: false,
        paused: false,
        mode: 'fetch',       // 'fetch' = background requests, 'navigate' = drive the tab
        baseUrl: '',         // search url without the page param
        startPage: 1,
        endPage: 1,
        currentPage: 1,
        delay: 1200,
        jobs: [],
        pagesDone: [],
        error: '',
        collapsed: false,
    };

    let state = loadState();
    let ticking = false;     // fetch-loop guard

    function loadState() {
        try {
            const raw = localStorage.getItem(STATE_KEY);
            if (!raw) return { ...DEFAULT_STATE };
            return { ...DEFAULT_STATE, ...JSON.parse(raw) };
        } catch (e) {
            return { ...DEFAULT_STATE };
        }
    }

    function saveState() {
        try {
            localStorage.setItem(STATE_KEY, JSON.stringify(state));
        } catch (e) {
            // quota blown - keep running, just lose resume-after-reload
            console.warn('[wf-export] could not persist state:', e);
        }
    }

    /* ------------------------------------------------------------------ *
     * page detection helpers
     * ------------------------------------------------------------------ */

    function isListingPage(doc = document) {
        return !!doc.querySelector('[data-testid="startup-header"]');
    }

    // Strip the page param so we can rebuild any page of the same search.
    function baseUrlFromLocation() {
        const u = new URL(location.href);
        u.searchParams.delete('page');
        u.hash = '';
        return u.toString();
    }

    function pageUrl(base, n) {
        const u = new URL(base);
        if (n > 1) u.searchParams.set('page', n);
        else u.searchParams.delete('page');
        return u.toString();
    }

    function currentPageNumber(doc = document) {
        const cur = doc.querySelector('nav[aria-label="Pagination Navigation"] a[aria-current="true"]');
        if (cur) {
            const m = (cur.getAttribute('aria-label') || '').match(/Page (\d+)/);
            if (m) return +m[1];
        }
        const p = new URL(location.href).searchParams.get('page');
        return p ? +p : 1;
    }

    // Wellfound only renders a window of page links, so this is the highest
    // *known* page, not necessarily the true last one.
    function lastKnownPage(doc = document) {
        let max = currentPageNumber(doc);
        doc.querySelectorAll('nav[aria-label="Pagination Navigation"] a[aria-label^="Go to page"]').forEach(a => {
            const m = a.getAttribute('aria-label').match(/(\d+)/);
            if (m) max = Math.max(max, +m[1]);
        });
        return max;
    }

    function hasNextPage(doc = document) {
        return !!doc.querySelector('nav[aria-label="Pagination Navigation"] a[aria-label="Next page"]');
    }

    /* ------------------------------------------------------------------ *
     * scraping
     * ------------------------------------------------------------------ */

    const txt = el => (el ? el.textContent.replace(/\s+/g, ' ').trim() : '');

    // Climb from the company header to the element that also holds its job rows.
    function companyCard(header) {
        let node = header.parentElement;
        let best = null;
        for (let i = 0; node && i < 8; i++) {
            if (node.querySelectorAll('[data-testid="startup-header"]').length > 1) break;
            best = node;
            if (node.querySelector('a[href*="/jobs/"]')) break;
            node = node.parentElement;
        }
        return best;
    }

    function jobRow(link) {
        const direct = link.closest('div[class*="min-h-["]');
        if (direct) return direct;
        let node = link.parentElement;
        for (let i = 0; node && i < 4; i++) {
            if (node.querySelectorAll('a[href*="/jobs/"]').length > 1) break;
            if (node.querySelector('span.pl-1')) return node;
            node = node.parentElement;
        }
        return link.parentElement;
    }

    /* --- typed value parsers ------------------------------------------- */

    const CURRENCY_SIGNS = { '$': 'USD', '€': 'EUR', '£': 'GBP', '₹': 'INR', '¥': 'JPY' };

    // "$150k – $220k", "$75k – $150k CAD", "$120,000 - $160,000", "$45 per hour"
    function parseSalary(raw) {
        const out = { salaryMinK: null, salaryMaxK: null, salaryCurrency: '', salaryPeriod: '', salaryRaw: raw };
        if (!raw) return out;

        const code = raw.match(/\b(USD|CAD|AUD|EUR|GBP|INR|SGD|NZD|CHF|JPY|BRL|MXN|ZAR)\b/i);
        const sign = raw.match(/[$€£₹¥]/);
        out.salaryCurrency = code ? code[1].toUpperCase() : (sign ? CURRENCY_SIGNS[sign[0]] : '');

        const hourly = /per\s*hour|\/\s*hr|hourly/i.test(raw);
        out.salaryPeriod = hourly ? 'hour' : 'year';

        // each number, remembering whether it carried a "k" suffix
        const nums = [...raw.matchAll(/([\d,]+(?:\.\d+)?)\s*(k|m)?/gi)]
            .map(m => {
                let n = parseFloat(m[1].replace(/,/g, ''));
                if (!isFinite(n)) return null;
                const suffix = (m[2] || '').toLowerCase();
                if (suffix === 'k') return n;              // already in thousands
                if (suffix === 'm') return n * 1000;
                return n >= 1000 ? n / 1000 : n;           // 120000 -> 120, 45 -> 45
            })
            .filter(n => n !== null);

        if (!hourly && nums.length) {
            out.salaryMinK = nums[0];
            out.salaryMaxK = nums.length > 1 ? nums[nums.length - 1] : nums[0];
        }
        return out;
    }

    // "0.5% – 1.0%", "No equity"
    function parseEquity(raw) {
        const out = { equityMinPct: null, equityMaxPct: null, equityRaw: raw };
        if (!raw) return out;
        if (/no equity/i.test(raw)) { out.equityMinPct = 0; out.equityMaxPct = 0; return out; }
        const nums = [...raw.matchAll(/([\d.]+)\s*%/g)].map(m => parseFloat(m[1])).filter(isFinite);
        if (nums.length) {
            out.equityMinPct = nums[0];
            out.equityMaxPct = nums[nums.length - 1];
        }
        return out;
    }

    // "7 days ago", "a month ago", "yesterday", "3 hours ago"
    function parsePostedDays(raw) {
        if (!raw) return null;
        const s = raw.toLowerCase();
        if (/just now|today|minute|second|hour/.test(s)) return 0;
        if (/yesterday/.test(s)) return 1;
        const m = s.match(/(\d+|a|an)\s*(day|week|month|year)/);
        if (!m) return null;
        const n = /^\d+$/.test(m[1]) ? +m[1] : 1;
        return n * { day: 1, week: 7, month: 30, year: 365 }[m[2]];
    }

    function splitMeta(row) {
        let salaryRaw = '', equityRaw = '', location = '', extra = '', expRaw = '';

        row.querySelectorAll('span.pl-1').forEach(span => {
            // "+2" badge for additional locations lives inside the location span
            const badge = span.querySelector('span');
            const badgeText = badge ? txt(badge) : '';
            if (badge) badge.remove();
            const value = txt(span);
            if (badge) span.appendChild(badge); // put it back, we may re-scrape

            if (!value) return;

            if (/years?\s+of\s+exp/i.test(value)) {
                expRaw = value;
            } else if (/^[$€£₹¥]/.test(value) || /\bequity\b/i.test(value) || /%/.test(value)) {
                // "$150k – $220k • 0.5% – 1.0%"
                value.split('•').map(s => s.trim()).forEach(part => {
                    if (/%/.test(part) || /equity/i.test(part)) equityRaw = equityRaw || part;
                    else salaryRaw = salaryRaw || part;
                });
            } else {
                location = value;
                extra = badgeText;
            }
        });

        const lower = location.toLowerCase();
        const expNum = expRaw.match(/(\d+(?:\.\d+)?)/);
        const extraNum = extra.match(/(\d+)/);

        return {
            ...parseSalary(salaryRaw),
            ...parseEquity(equityRaw),
            location,
            extraLocationCount: extraNum ? +extraNum[1] : 0,
            workplace: lower.includes('remote only') ? 'remote only'
                : lower.includes('remote') ? 'remote'
                : lower.includes('hybrid') ? 'hybrid'
                : lower.includes('in office') ? 'in office' : '',
            isRemote: /remote/.test(lower),
            experienceYears: expNum ? +expNum[1] : null,
        };
    }

    // "1-10 Employees", "51-200 Employees", "1000+ Employees"
    function parseSize(raw) {
        const out = { employeesMin: null, employeesMax: null, companySizeRaw: raw };
        if (!raw) return out;
        const nums = [...raw.matchAll(/([\d,]+)/g)].map(m => +m[1].replace(/,/g, '')).filter(isFinite);
        if (nums.length) {
            out.employeesMin = nums[0];
            out.employeesMax = nums.length > 1 ? nums[nums.length - 1] : (/\+/.test(raw) ? null : nums[0]);
        }
        return out;
    }

    function scrapeDoc(doc, pageNum) {
        const rows = [];
        doc.querySelectorAll('[data-testid="startup-header"]').forEach(header => {
            const card = companyCard(header);
            if (!card) return;

            const companyLink = header.querySelector('a[href*="/company/"]');
            const company = txt(header.querySelector('h2')) || txt(companyLink);
            const companyUrl = companyLink ? new URL(companyLink.getAttribute('href'), location.origin).toString() : '';
            const spans = header.querySelectorAll('span');
            let description = '', size = '';
            spans.forEach(s => {
                const v = txt(s);
                if (!v) return;
                if (/employees?$/i.test(v)) size = size || v;
                else if (!/actively hiring/i.test(v) && v.length > 12) description = description || v;
            });
            const activelyHiring = /actively hiring/i.test(txt(header));
            const badges = [...card.querySelectorAll('li .line-clamp-1')].map(txt).filter(Boolean).join(' | ');

            const links = [...card.querySelectorAll('a[href*="/jobs/"]')]
                .filter(a => /\/jobs\/\d+/.test(a.getAttribute('href') || ''));

            links.forEach(link => {
                const row = jobRow(link);
                const href = link.getAttribute('href');
                const url = new URL(href, location.origin).toString();
                const idMatch = href.match(/\/jobs\/(\d+)/);

                const typeTag = row.querySelector('span[class*="bg-accent-yellow"]');
                const posted = [...row.querySelectorAll('span[class*="text-dark-a"]')]
                    .map(txt).find(Boolean) || '';

                rows.push({
                    page: pageNum,
                    company,
                    companyUrl,
                    ...parseSize(size),
                    companyDescription: description,
                    activelyHiring,
                    companyBadges: badges,
                    title: txt(link),
                    jobId: idMatch ? +idMatch[1] : null,
                    jobUrl: url,
                    jobType: txt(typeTag),
                    ...splitMeta(row),
                    postedDaysAgo: parsePostedDays(posted),
                    postedRaw: posted,
                    scrapedAt: new Date().toISOString(),
                });
            });
        });
        return rows;
    }

    function mergeJobs(rows) {
        const seen = new Set(state.jobs.map(j => j.jobUrl));
        let added = 0;
        rows.forEach(r => {
            if (seen.has(r.jobUrl)) return;
            seen.add(r.jobUrl);
            state.jobs.push(r);
            added++;
        });
        return added;
    }

    /* ------------------------------------------------------------------ *
     * export
     * ------------------------------------------------------------------ */

    // [object key, CSV header] — headers carry the unit so numbers stay numbers.
    const COLUMNS = [
        ['page', 'page'],
        ['company', 'company'],
        ['title', 'title'],
        ['jobType', 'job_type'],
        ['salaryMinK', 'salary_min_K'],
        ['salaryMaxK', 'salary_max_K'],
        ['salaryCurrency', 'salary_currency'],
        ['salaryPeriod', 'salary_period'],
        ['salaryRaw', 'salary_raw'],
        ['equityMinPct', 'equity_min_pct'],
        ['equityMaxPct', 'equity_max_pct'],
        ['equityRaw', 'equity_raw'],
        ['location', 'location'],
        ['extraLocationCount', 'extra_locations'],
        ['workplace', 'workplace'],
        ['isRemote', 'is_remote'],
        ['experienceYears', 'experience_years'],
        ['postedDaysAgo', 'posted_days_ago'],
        ['postedRaw', 'posted_raw'],
        ['jobUrl', 'job_url'],
        ['jobId', 'job_id'],
        ['companyUrl', 'company_url'],
        ['employeesMin', 'employees_min'],
        ['employeesMax', 'employees_max'],
        ['companySizeRaw', 'company_size_raw'],
        ['activelyHiring', 'actively_hiring'],
        ['companyBadges', 'company_badges'],
        ['companyDescription', 'company_description'],
        ['scrapedAt', 'scraped_at'],
    ];

    function toCSV(jobs) {
        // numbers and booleans go out bare so spreadsheets type them correctly;
        // only text gets quoted, and only when it needs it.
        const esc = v => {
            if (v === null || v === undefined || v === '') return '';
            if (typeof v === 'number') return isFinite(v) ? String(v) : '';
            if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE';
            const s = String(v);
            return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
        };
        const lines = [COLUMNS.map(c => c[1]).join(',')];
        jobs.forEach(j => lines.push(COLUMNS.map(c => esc(j[c[0]])).join(',')));
        return lines.join('\r\n');
    }

    function download(content, filename, mime) {
        const blob = new Blob([content], { type: mime + ';charset=utf-8' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
    }

    function stamp() {
        const slug = (new URL(state.baseUrl || location.href).pathname.replace(/^\/|\/$/g, '') || 'wellfound')
            .replace(/[^\w-]+/g, '-');
        return `wellfound-${slug}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}`;
    }

    /* ------------------------------------------------------------------ *
     * crawl loop
     * ------------------------------------------------------------------ */

    const sleep = ms => new Promise(r => setTimeout(r, ms));

    // Sleep in slices so pause/stop react quickly.
    async function interruptibleSleep(ms) {
        const step = 150;
        for (let waited = 0; waited < ms; waited += step) {
            if (!state.running || state.paused) return;
            await sleep(step);
        }
    }

    async function waitWhilePaused() {
        while (state.running && state.paused) await sleep(200);
        return state.running;
    }

    async function fetchPage(n) {
        const res = await fetch(pageUrl(state.baseUrl, n), {
            credentials: 'include',
            headers: { 'Accept': 'text/html' },
        });
        if (!res.ok) throw new Error(`HTTP ${res.status} on page ${n}`);
        const html = await res.text();
        return new DOMParser().parseFromString(html, 'text/html');
    }

    async function runFetchLoop() {
        if (ticking) return;
        ticking = true;
        try {
            while (state.running && state.currentPage <= state.endPage) {
                if (!(await waitWhilePaused())) break;

                const n = state.currentPage;
                setStatus(`Fetching page ${n} / ${state.endPage}…`);
                let doc;
                try {
                    doc = await fetchPage(n);
                } catch (e) {
                    state.error = e.message + ' — paused. Resume to retry, or switch to Navigate mode.';
                    state.paused = true;
                    saveState(); render();
                    continue;
                }

                if (!isListingPage(doc)) {
                    // Server response didn't include rendered listings (bot check or
                    // client-only render). Fall back to driving the tab instead.
                    state.mode = 'navigate';
                    state.error = 'Background fetch returned no listings — switched to Navigate mode.';
                    saveState(); render();
                    ticking = false;
                    return startNavigateStep();
                }

                const added = mergeJobs(scrapeDoc(doc, n));
                if (!state.pagesDone.includes(n)) state.pagesDone.push(n);
                state.error = '';

                const noMore = !hasNextPage(doc);
                state.currentPage = n + 1;
                saveState(); render();
                setStatus(`Page ${n}: +${added} new (${state.jobs.length} total)`);

                if (noMore) { setStatus(`Reached last page (${n}).`); break; }
                if (state.currentPage > state.endPage) break;

                await interruptibleSleep(state.delay + Math.random() * 400);
            }
        } finally {
            ticking = false;
        }
        if (state.running && state.currentPage > state.endPage) finish();
        else render();
    }

    // Navigate mode: scrape whatever is on screen, then move the tab on.
    function startNavigateStep() {
        if (!state.running || state.paused) { render(); return; }

        const n = currentPageNumber();
        if (n >= state.startPage && n <= state.endPage && !state.pagesDone.includes(n)) {
            const added = mergeJobs(scrapeDoc(document, n));
            state.pagesDone.push(n);
            state.currentPage = n + 1;
            saveState();
            setStatus(`Page ${n}: +${added} new (${state.jobs.length} total)`);
        }

        if (state.currentPage > state.endPage || !hasNextPage()) { finish(); return; }

        render();
        setTimeout(() => {
            if (!state.running || state.paused) { render(); return; }
            setStatus(`Loading page ${state.currentPage}…`);
            location.href = pageUrl(state.baseUrl, state.currentPage);
        }, state.delay);
    }

    function start() {
        if (!isListingPage()) { setStatus('No job listings on this page.'); return; }
        state.baseUrl = baseUrlFromLocation();
        state.startPage = Math.max(1, +ui.from.value || 1);
        state.endPage = Math.max(state.startPage, +ui.to.value || state.startPage);
        state.delay = Math.max(200, +ui.delay.value || 1200);
        state.mode = ui.mode.value;
        state.currentPage = state.startPage;
        state.running = true;
        state.paused = false;
        state.error = '';
        if (ui.fresh.checked) { state.jobs = []; state.pagesDone = []; }
        saveState(); render();

        if (state.mode === 'navigate' && currentPageNumber() !== state.startPage) {
            location.href = pageUrl(state.baseUrl, state.startPage);
            return;
        }
        state.mode === 'fetch' ? runFetchLoop() : startNavigateStep();
    }

    function pause() { state.paused = true; saveState(); render(); setStatus('Paused.'); }

    function resume() {
        if (!state.running) return;
        state.paused = false; state.error = ''; saveState(); render();
        state.mode === 'fetch' ? runFetchLoop() : startNavigateStep();
    }

    function stop() {
        state.running = false; state.paused = false; saveState(); render();
        setStatus('Stopped.');
    }

    function finish() {
        state.running = false; state.paused = false; saveState(); render();
        setStatus(`Done — ${state.jobs.length} jobs from ${state.pagesDone.length} pages.`);
    }

    /* ------------------------------------------------------------------ *
     * UI
     * ------------------------------------------------------------------ */

    const ui = {};

    function injectStyles() {
        const css = `
        #wf-exp{position:fixed;right:16px;bottom:16px;z-index:2147483000;width:310px;
            font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
            background:#fff;color:#111;border:1px solid #d8d3d6;border-radius:10px;
            box-shadow:0 8px 28px rgba(0,0,0,.18);overflow:hidden}
        #wf-exp header{display:flex;align-items:center;justify-content:space-between;
            padding:8px 10px;background:#1f1b1e;color:#fff;cursor:move;user-select:none}
        #wf-exp header b{font-size:12px;letter-spacing:.3px}
        #wf-exp header button{background:none;border:0;color:#fff;cursor:pointer;font-size:14px;padding:0 4px}
        #wf-exp .wf-body{padding:10px}
        #wf-exp.wf-collapsed .wf-body{display:none}
        #wf-exp .wf-row{display:flex;gap:6px;align-items:center;margin-bottom:7px}
        #wf-exp label{font-size:11px;color:#6b6167;flex:0 0 auto}
        #wf-exp input[type=number],#wf-exp select{flex:1;min-width:0;padding:4px 6px;
            border:1px solid #d8d3d6;border-radius:5px;font-size:12px;background:#fff;color:#111}
        #wf-exp button.wf-btn{flex:1;padding:6px 8px;border:1px solid #1f1b1e;border-radius:6px;
            background:#1f1b1e;color:#fff;font-size:12px;font-weight:600;cursor:pointer}
        #wf-exp button.wf-btn.wf-alt{background:#fff;color:#1f1b1e}
        #wf-exp button.wf-btn:disabled{opacity:.4;cursor:default}
        #wf-exp .wf-stat{background:#f6f4f5;border-radius:6px;padding:6px 8px;font-size:11px;
            color:#4a4348;margin-bottom:7px;max-height:70px;overflow:auto;word-break:break-word}
        #wf-exp .wf-err{color:#b0243b}
        #wf-exp .wf-count{font-size:20px;font-weight:700;line-height:1}
        #wf-exp .wf-sub{font-size:11px;color:#6b6167}
        #wf-exp .wf-chk{font-size:11px;color:#4a4348;display:flex;align-items:center;gap:5px}
        @media (prefers-color-scheme: dark){
            #wf-exp{background:#1b1719;color:#f2eff0;border-color:#3a3336}
            #wf-exp .wf-stat{background:#262123;color:#cfc7cb}
            #wf-exp input[type=number],#wf-exp select{background:#262123;color:#f2eff0;border-color:#3a3336}
            #wf-exp button.wf-btn{background:#f2eff0;color:#1b1719;border-color:#f2eff0}
            #wf-exp button.wf-btn.wf-alt{background:transparent;color:#f2eff0}
            #wf-exp label,#wf-exp .wf-sub,#wf-exp .wf-chk{color:#a49ba0}
        }`;
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
    }

    function buildPanel() {
        const known = lastKnownPage();
        const here = currentPageNumber();
        const el = document.createElement('div');
        el.id = 'wf-exp';
        el.innerHTML = `
        <header><b>WELLFOUND EXPORT</b><span><button data-act="min" title="Collapse">–</button></span></header>
        <div class="wf-body">
            <div class="wf-row">
                <div><span class="wf-count" data-el="count">0</span> <span class="wf-sub">jobs</span></div>
                <div style="margin-left:auto;text-align:right" class="wf-sub" data-el="pages">0 pages</div>
            </div>
            <div class="wf-row">
                <label>Pages</label>
                <input type="number" min="1" data-el="from" value="${here}">
                <span class="wf-sub">→</span>
                <input type="number" min="1" data-el="to" value="${known}">
            </div>
            <div class="wf-row">
                <label>Delay ms</label><input type="number" min="200" step="100" data-el="delay" value="${state.delay}">
                <label>Mode</label>
                <select data-el="mode">
                    <option value="fetch">Background</option>
                    <option value="navigate">Navigate</option>
                </select>
            </div>
            <div class="wf-row"><label class="wf-chk"><input type="checkbox" data-el="fresh" checked> clear previous results on start</label></div>
            <div class="wf-stat" data-el="status">Idle.</div>
            <div class="wf-row">
                <button class="wf-btn" data-act="start">Start</button>
                <button class="wf-btn wf-alt" data-act="pause">Pause</button>
                <button class="wf-btn wf-alt" data-act="stop">Stop</button>
            </div>
            <div class="wf-row">
                <button class="wf-btn wf-alt" data-act="csv">CSV</button>
                <button class="wf-btn wf-alt" data-act="json">JSON</button>
                <button class="wf-btn wf-alt" data-act="page">This page</button>
            </div>
        </div>`;
        document.body.appendChild(el);

        el.querySelectorAll('[data-el]').forEach(n => ui[n.dataset.el] = n);
        ui.root = el;
        ui.mode.value = state.mode;

        el.addEventListener('click', e => {
            const act = e.target.closest('[data-act]');
            if (!act) return;
            switch (act.dataset.act) {
                case 'start': state.running && state.paused ? resume() : start(); break;
                case 'pause': state.paused ? resume() : pause(); break;
                case 'stop': stop(); break;
                case 'csv':
                    if (!state.jobs.length) return setStatus('Nothing to export yet.');
                    download('﻿' + toCSV(state.jobs), stamp() + '.csv', 'text/csv');
                    break;
                case 'json':
                    if (!state.jobs.length) return setStatus('Nothing to export yet.');
                    download(JSON.stringify(state.jobs, null, 2), stamp() + '.json', 'application/json');
                    break;
                case 'page': {
                    const rows = scrapeDoc(document, currentPageNumber());
                    const added = mergeJobs(rows);
                    saveState(); render();
                    setStatus(`Scraped this page: ${rows.length} jobs (+${added} new).`);
                    break;
                }
                case 'min':
                    state.collapsed = !state.collapsed;
                    saveState(); render();
                    break;
            }
        });

        makeDraggable(el, el.querySelector('header'));
        return el;
    }

    function makeDraggable(el, handle) {
        let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
        handle.addEventListener('mousedown', e => {
            if (e.target.closest('button')) return;
            dragging = true;
            const r = el.getBoundingClientRect();
            sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top;
            el.style.right = 'auto'; el.style.bottom = 'auto';
            el.style.left = ox + 'px'; el.style.top = oy + 'px';
            e.preventDefault();
        });
        document.addEventListener('mousemove', e => {
            if (!dragging) return;
            el.style.left = Math.max(0, ox + e.clientX - sx) + 'px';
            el.style.top = Math.max(0, oy + e.clientY - sy) + 'px';
        });
        document.addEventListener('mouseup', () => { dragging = false; });
    }

    function setStatus(msg) {
        if (ui.status) {
            ui.status.textContent = msg;
            ui.status.classList.toggle('wf-err', /error|http \d|failed/i.test(msg));
        }
    }

    function render() {
        if (!ui.root) return;
        ui.root.classList.toggle('wf-collapsed', !!state.collapsed);
        ui.count.textContent = state.jobs.length;
        ui.pages.textContent = `${state.pagesDone.length} pages`;

        const startBtn = ui.root.querySelector('[data-act="start"]');
        const pauseBtn = ui.root.querySelector('[data-act="pause"]');
        const stopBtn = ui.root.querySelector('[data-act="stop"]');

        startBtn.textContent = state.running ? (state.paused ? 'Resume' : 'Running…') : 'Start';
        startBtn.disabled = state.running && !state.paused;
        pauseBtn.textContent = state.paused ? 'Resume' : 'Pause';
        pauseBtn.disabled = !state.running;
        stopBtn.disabled = !state.running;
        ui.mode.value = state.mode;

        [ui.from, ui.to, ui.delay, ui.mode, ui.fresh].forEach(n => n.disabled = state.running);

        if (state.error) setStatus(state.error);
    }

    /* ------------------------------------------------------------------ *
     * boot
     * ------------------------------------------------------------------ */

    function boot() {
        if (document.getElementById('wf-exp')) return;
        if (!isListingPage() && !state.running) return;

        injectStyles();
        buildPanel();
        render();

        if (state.running) {
            ui.from.value = state.startPage;
            ui.to.value = state.endPage;
            if (state.paused) {
                setStatus(`Paused at page ${state.currentPage}. ${state.jobs.length} jobs collected.`);
            } else if (state.mode === 'navigate') {
                setStatus(`Resuming on page ${currentPageNumber()}…`);
                setTimeout(startNavigateStep, 600);
            } else {
                runFetchLoop();
            }
        } else {
            setStatus(isListingPage()
                ? `Ready. ${lastKnownPage()} pages visible in pagination.`
                : 'Open a Wellfound job search to begin.');
        }
    }

    // Wellfound routes client-side, so re-check when the URL changes.
    let lastHref = location.href;
    setInterval(() => {
        if (location.href !== lastHref) {
            lastHref = location.href;
            setTimeout(boot, 800);
        }
    }, 1000);

    boot();
})();
