import fetch from 'node-fetch';
import { load as cheerioLoad } from 'cheerio';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import https from 'https';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

dotenv.config({ path: path.join(__dirname, '.env') });

const WOW_ADDON_DIR = process.env.WOW_ADDON_DIR; // optional; fallback to repo root
const REGIONS = (process.env.REGIONS || 'eu').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const BRACKETS = (process.env.BRACKETS || '2v2,3v3,shuffle,rbg,blitz').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const LOCALE = (process.env.LOCALE || 'en_US').toLowerCase(); // e.g. en_US, en_GB, de_DE
const MAX_ENTRIES_PER_BRACKET = (() => {
  const raw = process.env.MAX_ENTRIES_PER_BRACKET;
  if (raw === '0') return 0;
  const n = parseInt(raw || '5000', 10);
  return Number.isFinite(n) ? n : 5000; // 0 disables the cap
})();
const MAX_PAGES_PER_BRACKET = (() => {
  const raw = process.env.MAX_PAGES_PER_BRACKET;
  if (raw === '0') return 0;
  const n = parseInt(raw || '0', 10);
  return Number.isFinite(n) ? n : 0; // 0 = unlimited
})();
// Brackets that are class-filtered on the site
const CLASS_FILTER_BRACKETS = (process.env.CLASS_FILTER_BRACKETS || 'shuffle,blitz').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
// Concurrency and pacing controls for performance
const CONCURRENCY = (() => {
  const n = parseInt(process.env.CONCURRENCY || '6', 10);
  return Number.isFinite(n) && n > 0 ? n : 6;
})();
const PER_REQUEST_DELAY_MS = (() => {
  const n = parseInt(process.env.PER_REQUEST_DELAY_MS || '200', 10);
  return Number.isFinite(n) && n >= 0 ? n : 200;
})();
// Limit number of class filters processed per bracket (0 = unlimited)
const MAX_CLASS_FILTERS_PER_BRACKET = (() => {
  const raw = process.env.MAX_CLASS_FILTERS_PER_BRACKET;
  if (raw === '0') return 0;
  const n = parseInt(raw || '14', 10); // ~number of classes
  return Number.isFinite(n) && n > 0 ? n : 14;
})();
// Control whether to use class-only filtering (faster) or class+spec (slower)
const CLASS_FILTER_MODE = (process.env.CLASS_FILTER_MODE || 'class-only').toLowerCase(); // 'class-only' | 'class-spec'
// Bracket-specific page caps (0 = unlimited). Team brackets: 2v2/3v3/rbg; Class-filtered: shuffle/blitz
const MAX_PAGES_TEAM_BRACKETS = (() => {
  const raw = process.env.MAX_PAGES_TEAM_BRACKETS;
  if (raw === '0') return 0;
  const n = parseInt(raw || '10', 10);
  return Number.isFinite(n) ? n : 10;
})();
const MAX_PAGES_CLASS_BRACKETS = (() => {
  const raw = process.env.MAX_PAGES_CLASS_BRACKETS;
  if (raw === '0') return 0;
  const n = parseInt(raw || '10', 10);
  return Number.isFinite(n) ? n : 10;
})();
const EARLY_STOP_EMPTY_PAGES = (() => {
  const n = parseInt(process.env.EARLY_STOP_EMPTY_PAGES || '2', 10);
  return Number.isFinite(n) && n >= 0 ? n : 2;
})();

const LOCALE_TO_PATH = {
  'en_us': 'en-us',
  'en_gb': 'en-gb',
  'de_de': 'de-de',
  'fr_fr': 'fr-fr',
  'es_es': 'es-es',
  'es_mx': 'es-mx',
  'it_it': 'it-it',
  'pt_br': 'pt-br',
  'ru_ru': 'ru-ru',
  'ko_kr': 'ko-kr',
  'zh_tw': 'zh-tw',
  'zh_cn': 'zh-cn',
};

const REGION_TO_QS = { us: 'US', eu: 'EU', kr: 'KR', tw: 'TW' };

const BRACKET_TO_PATH = {
  '2v2': '2v2',
  '3v3': '3v3',
  'shuffle': 'shuffle',
  'rbg': 'battlegrounds',
  'blitz': 'blitz',
};

// Known class/spec combinations used as a fallback when we cannot discover filter URLs dynamically
const CLASS_SPEC_MAP = {
  'death-knight': ['blood', 'frost', 'unholy'],
  'demon-hunter': ['havoc', 'vengeance'],
  'druid': ['balance', 'feral', 'guardian', 'restoration'],
  'evoker': ['augmentation', 'devastation', 'preservation'],
  'hunter': ['beast-mastery', 'marksmanship', 'survival'],
  'mage': ['arcane', 'fire', 'frost'],
  'monk': ['brewmaster', 'mistweaver', 'windwalker'],
  'paladin': ['holy', 'protection', 'retribution'],
  'priest': ['discipline', 'holy', 'shadow'],
  'rogue': ['assassination', 'outlaw', 'subtlety'],
  'shaman': ['elemental', 'enhancement', 'restoration'],
  'warlock': ['affliction', 'demonology', 'destruction'],
  'warrior': ['arms', 'fury', 'protection'],
};

function localePathForRegion(region) {
  const l = LOCALE;
  // Prefer explicit LOCALE when it's not generic English
  if (l && !l.startsWith('en_') && LOCALE_TO_PATH[l]) return LOCALE_TO_PATH[l];
  // For English, pick region-appropriate default to ensure correct leaderboards
  if (region === 'eu') return 'en-gb';
  if (region === 'us') return 'en-us';
  // Fallback
  return LOCALE_TO_PATH[l] || 'en-us';
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; }

function normalizeRealmString(realm) {
  let s = '' + (realm || '');
  try { s = s.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch {}
  s = s.toLowerCase();
  const parts = s.split(/[\s\-']/g).filter(Boolean);
  const titled = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
  return titled;
}

function buildLua(dataByRegion, season = 0) {
  const now = Math.floor(Date.now() / 1000);
  const lines = [];
  lines.push('-- Generated by PvPScout scraper');
  lines.push('PvPScoutData = PvPScoutData or {}');
  lines.push(`PvPScoutData.season = ${Number(season || 0)}`);
  lines.push(`PvPScoutData.updatedAt = ${now}`);
  lines.push('PvPScoutData.data = PvPScoutData.data or {}');
  for (const region of Object.keys(dataByRegion)) {
    lines.push(`PvPScoutData.data["${region}"] = PvPScoutData.data["${region}"] or {}`);
    const reg = dataByRegion[region];
    for (const bracket of Object.keys(reg)) {
      lines.push(`PvPScoutData.data["${region}"]["${bracket}"] = {}`);
      const all = Object.entries(reg[bracket]);
      const entries = (MAX_ENTRIES_PER_BRACKET > 0) ? all.slice(0, MAX_ENTRIES_PER_BRACKET) : all;
      for (const [key, val] of entries) {
        const rating = Number(val.rating || 0);
        const rank = Number(val.rank || 0);
        const bySpec = (val.bySpec && typeof val.bySpec === 'object') ? val.bySpec : null;
        if (bySpec) {
          const specEntries = Object.entries(bySpec)
            .map(([s, v]) => ` ["${s}"] = { rating = ${Number(v.rating||0)}, rank = ${Number(v.rank||0)} }`)
            .join(',');
          lines.push(`PvPScoutData.data["${region}"]["${bracket}"]["${key}"] = { rating = ${rating}, rank = ${rank}, bySpec = {${specEntries}} }`);
        } else {
          lines.push(`PvPScoutData.data["${region}"]["${bracket}"]["${key}"] = { rating = ${rating}, rank = ${rank} }`);
        }
      }
    }
  }
  lines.push('');
  return lines.join('\n');
}

function makeUrl(localePath, bracketPath, regionQs) {
  return `https://worldofwarcraft.blizzard.com/${localePath}/game/pvp/leaderboards/${bracketPath}?region=${regionQs}`;
}

let sharedAgent;
function getHttpsAgent() {
  if (sharedAgent) return sharedAgent;
  const insecure = (process.env.INSECURE_TLS || '').toLowerCase() === '1' || (process.env.INSECURE_TLS || '').toLowerCase() === 'true';
  const caPath = process.env.CA_CERT_PATH || process.env.NODE_EXTRA_CA_CERTS;
  if (insecure) {
    sharedAgent = new https.Agent({ rejectUnauthorized: false, keepAlive: true });
    return sharedAgent;
  }
  if (caPath && fs.existsSync(caPath)) {
    try {
      const ca = fs.readFileSync(caPath, 'utf8');
      sharedAgent = new https.Agent({ ca, keepAlive: true });
      return sharedAgent;
    } catch { /* ignore */ }
  }
  sharedAgent = new https.Agent({ keepAlive: true });
  return sharedAgent;
}

function acceptLanguageForUrl(u) {
  try {
    const url = new URL(u);
    const p = url.pathname.toLowerCase();
    if (p.includes('/en-gb/')) return 'en-GB,en;q=0.9';
    if (p.includes('/en-us/')) return 'en-US,en;q=0.9';
  } catch {}
  // Fallback to configured LOCALE
  const l = (LOCALE || 'en_US').replace('_', '-');
  return l;
}

async function fetchHTML(url) {
  const res = await fetch(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 PvPScout/0.1 (+https://github.com/dominikreckziegel/PvPScout) polite-scraper',
      'Accept-Language': acceptLanguageForUrl(url),
      'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      'Cache-Control': 'no-cache',
    },
    agent: getHttpsAgent(),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.text();
}

function parseLeaderboard(html, desiredRegion) {
  const $ = cheerioLoad(html);
  const data = [];
  const table = $('table, div[data-testid="leaderboard-table"], div.Leaderboard, div.pvp-leaderboard').first();
  let rows = table.length ? table.find('tbody tr') : $('tr');
  // Robust parser for character links: /character/{region}/{realm}/{name}
  const parseCharacterHref = (href) => {
    try {
      const u = new URL(href, 'https://worldofwarcraft.blizzard.com');
      const parts = (u.pathname || '').split('/').filter(Boolean);
      const idx = parts.findIndex(p => p.toLowerCase() === 'character');
      if (idx >= 0 && parts.length >= idx + 4) {
        return {
          region: (parts[idx + 1] || '').toLowerCase(),
          realmSlug: parts[idx + 2] || '',
          charName: parts[idx + 3] || '',
        };
      }
    } catch {}
    return null;
  };
  rows.each((_, tr) => {
    const tds = $(tr).find('td');
    if (tds.length < 3) return;
    let rankText = $(tds[0]).text().trim();
    let playerText = $(tds[1]).text().trim();
    let ratingText = $(tds[2]).text().trim();
    if (!/\d/.test(ratingText)) {
      for (let i = 2; i < tds.length; i++) {
        const txt = $(tds[i]).text().trim();
        if (/^\d{3,4}$/.test(txt)) { ratingText = txt; break; }
      }
    }
    const rank = parseInt(rankText.replace(/[^0-9]/g, ''), 10);
    const rating = parseInt(ratingText.replace(/[^0-9]/g, ''), 10);
    let name = '';
    let realm = '';
    const link = $(tds).find('a[href*="/character/"]').first();
    if (link.length) {
      try {
        const href = link.attr('href') || '';
        const info = parseCharacterHref(href);
        if (info) {
          if (desiredRegion && info.region && info.region.toLowerCase() !== desiredRegion.toLowerCase()) return;
          name = decodeURIComponent(info.charName || '');
          realm = String(info.realmSlug || '').replace(/-/g, ' ');
        }
      } catch { /* ignore */ }
    }
    if (!name) {
      const m = playerText.match(/([A-Za-zÀ-ÖØ-öø-ÿ\-']+)[\s\-–—]+([A-Za-zÀ-ÖØ-öø-ÿ\-']+)/);
      if (m) { name = m[1]; realm = m[2]; } else { name = playerText; }
    }
    if (Number.isFinite(rank) && Number.isFinite(rating) && name) data.push({ rank, rating, name, realm });
  });

  if (data.length === 0) {
    const nextData = $('#__NEXT_DATA__').first();
    if (nextData && nextData.length) {
      try {
        const obj = JSON.parse(nextData.text());
        const acc = [];
        const visit = (o) => {
          if (!o) return;
          if (Array.isArray(o)) { for (const it of o) visit(it); return; }
          if (typeof o === 'object') {
            if (Array.isArray(o.entries)) { for (const e of o.entries) acc.push(e); }
            for (const k of Object.keys(o)) visit(o[k]);
          }
        };
        visit(obj);
        for (const e of acc) {
          const rank = e && (e.rank || e.rating_position);
          const rating = e && (e.rating || e.pvp_rating);
          const cname = e && e.character && (e.character.name || e.character.character && e.character.character.name);
          const crealm = e && e.character && e.character.realm && (e.character.realm.name || e.character.realm.slug);
          const cregion = e && e.character && (e.character.region || e.character.realm && e.character.realm.region);
          if (desiredRegion && cregion && String(cregion).toLowerCase() !== desiredRegion.toLowerCase()) continue;
          if (rank && rating && cname) data.push({ rank: Number(rank), rating: Number(rating), name: String(cname), realm: String(crealm || '') });
        }
      } catch { /* ignore */ }
    }
  }

  if (data.length === 0) {
    $('script').each((_, s) => {
      const txt = $(s).html() || '';
      if (/leaderboard/i.test(txt) && /rank/i.test(txt) && /rating/i.test(txt)) {
        try {
          const jMatches = txt.match(/\{[\s\S]*\}/g) || [];
          for (const jm of jMatches) {
            const o = JSON.parse(jm);
            if (Array.isArray(o.entries)) {
              for (const e of o.entries) {
                if (e && e.rank && e.rating && e.character && e.character.name && e.character.realm) {
                  data.push({ rank: e.rank, rating: e.rating, name: e.character.name, realm: e.character.realm.name || e.character.realm.slug || '' });
                }
              }
            }
          }
        } catch { /* ignore */ }
      }
    });
  }
  return data;
}

function extractMaxPages(html) {
  const $ = cheerioLoad(html);
  let maxPage = 1;
  $('a[href*="page="]').each((_, a) => {
    const href = $(a).attr('href') || '';
    const m = href.match(/[?&]page=(\d+)/i);
    if (m) {
      const p = parseInt(m[1], 10);
      if (Number.isFinite(p) && p > maxPage) maxPage = p;
    }
  });
  return maxPage;
}

function unique(arr) { return Array.from(new Set(arr)); }

function resolveUrl(baseUrl, href) {
  try { return new URL(href, baseUrl).toString(); } catch { return href; }
}

function addOrReplacePageParam(u, page) {
  try {
    const url = new URL(u);
    url.searchParams.set('page', String(page));
    return url.toString();
  } catch {
    const sep = u.includes('?') ? '&' : '?';
    return `${u}${sep}page=${page}`;
  }
}

function appendQueryParams(u, paramsObj) {
  try {
    const url = new URL(u);
    for (const [k, v] of Object.entries(paramsObj)) {
      url.searchParams.set(k, v);
    }
    return url.toString();
  } catch {
    const sep = u.includes('?') ? '&' : '?';
    const qs = Object.entries(paramsObj).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
    return `${u}${sep}${qs}`;
  }
}

function extractClassFilterUrls(html, baseUrl, bracketPath) {
  const $ = cheerioLoad(html);
  const urls = [];
  let baseRegion = null;
  try { const bu = new URL(baseUrl); baseRegion = bu.searchParams.get('region') || null; } catch { baseRegion = null; }
  $('a[href]').each((_, a) => {
    const href = ($(a).attr('href') || '').trim();
    if (!href) return;
    if (!/leaderboards\//i.test(href)) return;
    if (!new RegExp(`/leaderboards/${bracketPath}`, 'i').test(href)) return;
    // Accept query-style filters OR path-style /{class}/{spec}
    const qMatch = /(class|spec|specialization|characterClass|classId|specId|filters)=/i.test(href);
    let pMatch = false;
    try {
      const u = new URL(href, baseUrl);
      const parts = (u.pathname || '').split('/').filter(Boolean);
      const idx = parts.findIndex(p => p.toLowerCase() === 'leaderboards');
      if (idx >= 0) {
        const after = parts.slice(idx + 1);
        if ((after[0] || '').toLowerCase() === bracketPath.toLowerCase() && after.length >= 2) {
          pMatch = true;
        }
      }
    } catch {}
    if (!qMatch && !pMatch) return;
    urls.push(resolveUrl(baseUrl, href));
  });
  $('option[value]').each((_, opt) => {
    const val = ($(opt).attr('value') || '').trim();
    if (!val) return;
    if (!/leaderboards\//i.test(val)) return;
    if (!new RegExp(`/leaderboards/${bracketPath}`, 'i').test(val)) return;
    const qMatch = /(class|spec|specialization|characterClass|classId|specId|filters)=/i.test(val);
    let pMatch = false;
    try {
      const u = new URL(val, baseUrl);
      const parts = (u.pathname || '').split('/').filter(Boolean);
      const idx = parts.findIndex(p => p.toLowerCase() === 'leaderboards');
      if (idx >= 0) {
        const after = parts.slice(idx + 1);
        if ((after[0] || '').toLowerCase() === bracketPath.toLowerCase() && after.length >= 2) {
          pMatch = true;
        }
      }
    } catch {}
    if (!qMatch && !pMatch) return;
    urls.push(resolveUrl(baseUrl, val));
  });
  // Canonicalize to class-only when requested: strip spec/specialization, normalize filters=class-<class>
  const canon = urls.map(u => {
    try {
      const url = new URL(u);
      // Normalize legacy params
      if (CLASS_FILTER_MODE === 'class-only') {
        const region = url.searchParams.get('region');
        let cls = url.searchParams.get('class') || url.searchParams.get('characterClass') || '';
        const spec = url.searchParams.get('spec') || url.searchParams.get('specialization') || '';
        const filters = url.searchParams.get('filters') || '';
        if (!cls && filters && /class-([a-z\-]+)/i.test(filters)) {
          const m = filters.match(/class-([a-z\-]+)/i);
          if (m) cls = m[1].toLowerCase();
        }
        if (cls) {
          url.search = '';
          url.searchParams.set('region', region || baseRegion || 'EU');
          // Prefer a generic 'class' param so we hit the class page including all specs
          url.searchParams.set('class', cls);
        }
        // Remove paging; we'll add it ourselves
        url.searchParams.delete('page');
      }
      return url.toString();
    } catch { return u; }
  });
  let out = unique(canon);
  if (MAX_CLASS_FILTERS_PER_BRACKET > 0) out = out.slice(0, MAX_CLASS_FILTERS_PER_BRACKET);
  return out;
}

function buildClassFilterCandidates(baseUrl) {
  // Prefer class-only filters to minimize requests
  const out = [];
  for (const cls of Object.keys(CLASS_SPEC_MAP)) {
    out.push(appendQueryParams(baseUrl, { class: cls }));
    out.push(appendQueryParams(baseUrl, { filters: `class-${cls}` }));
  }
  let uniqueOut = unique(out);
  if (MAX_CLASS_FILTERS_PER_BRACKET > 0) uniqueOut = uniqueOut.slice(0, MAX_CLASS_FILTERS_PER_BRACKET);
  return uniqueOut;
}

function buildSpecFilterCandidates(baseUrl) {
  // Generate explicit class+spec URLs for all 39 specs
  const out = [];
  for (const [cls, specs] of Object.entries(CLASS_SPEC_MAP)) {
    for (const spec of specs) {
      // Two variants to be robust across site parameter styles
      out.push(appendQueryParams(baseUrl, { class: cls, spec }));
      out.push(appendQueryParams(baseUrl, { filters: `class-${cls},spec-${spec}` }));
    }
  }
  let uniqueOut = unique(out);
  if (MAX_CLASS_FILTERS_PER_BRACKET > 0) uniqueOut = uniqueOut.slice(0, MAX_CLASS_FILTERS_PER_BRACKET);
  return uniqueOut;
}

function classSlugToPathSegment(cls) {
  // death-knight -> deathknight
  return String(cls || '').toLowerCase().replace(/[^a-z\-]/g, '').replace(/\-/g, '');
}

function buildPathSpecCandidates(locPath, bracketPath) {
  const base = `https://worldofwarcraft.blizzard.com/${locPath}/game/pvp/leaderboards/${bracketPath}`;
  const out = [];
  for (const [cls, specs] of Object.entries(CLASS_SPEC_MAP)) {
    const cseg = classSlugToPathSegment(cls);
    for (const spec of specs) {
      out.push(`${base}/${cseg}/${spec}`);
    }
  }
  let uniqueOut = unique(out);
  if (MAX_CLASS_FILTERS_PER_BRACKET > 0) uniqueOut = uniqueOut.slice(0, MAX_CLASS_FILTERS_PER_BRACKET);
  return uniqueOut;
}

function extractClassSpecFromUrl(u) {
  try {
    const url = new URL(u);
    // Path style: /leaderboards/{bracket}/{class}/{spec}
    const parts = (url.pathname || '').split('/').filter(Boolean);
    const idx = parts.findIndex(p => p.toLowerCase() === 'leaderboards');
    if (idx >= 0) {
      const after = parts.slice(idx + 1);
      // after[0] = bracket; after[1] = class (collapsed: deathknight); after[2] = spec
      if (after.length >= 3) {
        const spec = (after[2] || '').toLowerCase();
        if (spec) return { specSlug: spec };
      }
    }
    // Query style: ?spec=<slug> or filters=class-...,spec-<slug>
    const specQ = url.searchParams.get('spec') || url.searchParams.get('specialization') || '';
    if (specQ) return { specSlug: specQ.toLowerCase() };
    const filters = url.searchParams.get('filters') || '';
    const m = filters.match(/spec-([a-z\-]+)/i);
    if (m) return { specSlug: m[1].toLowerCase() };
  } catch { /* ignore */ }
  return { specSlug: null };
}

async function scrape() {
  const results = {};
  for (const region of REGIONS) {
    results[region] = {};
    for (const bracket of BRACKETS) {
      const bracketPath = BRACKET_TO_PATH[bracket];
      if (!bracketPath) continue;
      const locPath = localePathForRegion(region);
      const url = makeUrl(locPath, bracketPath, REGION_TO_QS[region] || 'EU');
      try {
        let allRows = [];
        // Base (possibly empty if page is class-filtered)
        const html = await fetchHTML(url);
        const maxPages = extractMaxPages(html);
        allRows.push(...parseLeaderboard(html, region));
        // Early-stop pagination for base
        let noNew = 0;
        const capTeam = CLASS_FILTER_BRACKETS.includes(bracket) ? MAX_PAGES_CLASS_BRACKETS : MAX_PAGES_TEAM_BRACKETS;
        const pagesCap = [maxPages]
          .concat(MAX_PAGES_PER_BRACKET > 0 ? [MAX_PAGES_PER_BRACKET] : [])
          .concat(capTeam > 0 ? [capTeam] : [])
          .reduce((a, b) => Math.min(a, b), Number.POSITIVE_INFINITY);
        const limit = Number.isFinite(pagesCap) ? pagesCap : maxPages;
        for (let page = 2; page <= limit; page++) {
          await sleep(PER_REQUEST_DELAY_MS);
          const purl = addOrReplacePageParam(url, page);
          const htmlP = await fetchHTML(purl);
          const rowsP = parseLeaderboard(htmlP, region);
          const before = allRows.length;
          if (rowsP && rowsP.length) allRows.push(...rowsP);
          if (allRows.length === before) {
            noNew += 1; if (noNew >= EARLY_STOP_EMPTY_PAGES) break;
          } else {
            noNew = 0;
          }
        }

        // If bracket is class-filtered, iterate all class/spec variants
        let classUrls = [];
        if (CLASS_FILTER_BRACKETS.includes(bracket)) {
          // For class-spec mode, prefer path-style candidates exclusively to avoid redundant variants
          if (CLASS_FILTER_MODE === 'class-spec') {
            const pathCandidates = buildPathSpecCandidates(locPath, bracketPath);
            if (pathCandidates && pathCandidates.length) {
              classUrls = unique(pathCandidates);
            } else {
              // Fallback: attempt to discover spec-filtered URLs or build query-style ones
              let discovered = extractClassFilterUrls(html, url, bracketPath) || [];
              const hasSpec = (u) => {
                try {
                  const uo = new URL(u);
                  const spec = uo.searchParams.get('spec') || uo.searchParams.get('specialization');
                  const filters = uo.searchParams.get('filters') || '';
                  return (spec && spec.length > 0) || /(^|,|&)spec-|specialization|specId/i.test(u) || /spec-/i.test(filters);
                } catch { return /spec-/i.test(String(u)); }
              };
              discovered = discovered.filter(hasSpec);
              if (!discovered.length) {
                discovered = buildSpecFilterCandidates(url);
              }
              classUrls = unique(discovered);
            }
          } else {
            // class-only mode
            classUrls = buildClassFilterCandidates(url);
          }
          classUrls = unique(classUrls);
          // Limit to MAX_CLASS_FILTERS_PER_BRACKET, and fetch in concurrent groups
          if (MAX_CLASS_FILTERS_PER_BRACKET > 0) classUrls = classUrls.slice(0, MAX_CLASS_FILTERS_PER_BRACKET);
          const groups = chunk(classUrls, CONCURRENCY);
          let processed = 0;
          for (const group of groups) {
            const resRows = await Promise.all(group.map(async (cu) => {
              try {
                await sleep(PER_REQUEST_DELAY_MS);
                const htmlC = await fetchHTML(cu);
                const rowsC1 = parseLeaderboard(htmlC, region);
                const { specSlug } = extractClassSpecFromUrl(cu);
                const maxPagesC = extractMaxPages(htmlC);
                const acc = rowsC1.map(e => ({ ...e, spec: specSlug || undefined }));
                const seen = new Set(acc.map(e => `${e.name}-${normalizeRealmString(e.realm)}`));
                let stop = 0;
                const capClass = CLASS_FILTER_BRACKETS.includes(bracket) ? MAX_PAGES_CLASS_BRACKETS : MAX_PAGES_TEAM_BRACKETS;
                // For class-filtered pages (shuffle/blitz), some sites may not expose pagination links.
                // Prefer dynamic detection: when pagination count isn't discoverable, probe additional pages with early-stop.
                // Use a modest fallback cap (12) to avoid stressing the site, unless explicit caps are configured.
                const desiredCapC = (MAX_PAGES_PER_BRACKET > 0) ? MAX_PAGES_PER_BRACKET : (capClass > 0 ? capClass : 0);
                let limitC;
                if (desiredCapC > 0) {
                  limitC = desiredCapC;
                } else if (Number.isFinite(maxPagesC) && maxPagesC > 1) {
                  limitC = maxPagesC;
                } else {
                  limitC = 12; // dynamic probe upper bound; early-stop will typically end sooner
                }
                for (let page = 2; page <= limitC; page++) {
                  await sleep(PER_REQUEST_DELAY_MS);
                  const purlC = addOrReplacePageParam(cu, page);
                  const htmlCP = await fetchHTML(purlC);
                  const rowsCP = parseLeaderboard(htmlCP, region);
                  let newCount = 0;
                  if (rowsCP && rowsCP.length) {
                    for (const e of rowsCP) {
                      const k = `${e.name}-${normalizeRealmString(e.realm)}`;
                      if (!seen.has(k)) {
                        seen.add(k);
                        acc.push({ ...e, spec: specSlug || undefined });
                        newCount++;
                      }
                    }
                  }
                  if (newCount === 0) { stop += 1; if (stop >= EARLY_STOP_EMPTY_PAGES) break; } else { stop = 0; }
                }
                return acc;
              } catch { return []; }
            }));
            for (const arr of resRows) allRows.push(...arr);
            processed += group.length;
          }
          console.log(`Processed ${processed} ${bracket} filter page(s) for ${region}`);
        }

        // Deduplicate by best rank per Name-Realm
        const map = {};
        for (const e of allRows) {
          if (!e || !e.name) continue;
          const key = `${e.name}-${normalizeRealmString(e.realm)}`;
          const existing = map[key] || { rating: 0, rank: 0, bySpec: {} };
          // Track per-spec when provided (esp. shuffle/blitz)
          if (e.spec) {
            const cur = existing.bySpec[e.spec];
            if (!cur || (e.rank && e.rank < cur.rank)) {
              existing.bySpec[e.spec] = { rating: e.rating, rank: e.rank };
            }
          }
          // Best overall for backward-compat and general brackets
          if (!existing.rank || (e.rank && e.rank < existing.rank)) {
            existing.rating = e.rating;
            existing.rank = e.rank;
          }
          map[key] = existing;
        }
        results[region][bracket] = map;
        let count = Object.keys(map).length;
        if (MAX_ENTRIES_PER_BRACKET > 0 && count > MAX_ENTRIES_PER_BRACKET) count = MAX_ENTRIES_PER_BRACKET;
        const clsNote = (classUrls && classUrls.length) ? ` + ${classUrls.length} class filters` : '';
        console.log(`Parsed ${count} rows for ${region} ${bracket}${clsNote}`);
        await sleep(1000);
      } catch (err) {
        console.error(`Scrape failed for ${region} ${bracket}: ${err.message}`);
      }
    }
  }

  let total = 0;
  for (const r of Object.keys(results)) {
    for (const b of Object.keys(results[r])) total += Object.keys(results[r][b] || {}).length;
  }
  if (total === 0) throw new Error('No leaderboard rows parsed; aborting to avoid empty Data.lua');

  const lua = buildLua(results, 0);
  let outPath;
  if (WOW_ADDON_DIR) {
    try { fs.mkdirSync(WOW_ADDON_DIR, { recursive: true }); } catch {}
    outPath = path.join(WOW_ADDON_DIR, 'Data.lua');
  } else {
    const root = path.resolve(path.join(__dirname, '..'));
    outPath = path.join(root, 'Data.lua');
  }
  // Always write Data.lua so updatedAt is refreshed; CI will decide whether to commit
  try {
    fs.writeFileSync(outPath, lua, 'utf8');
    console.log('Wrote', outPath);
  } catch (e) {
    console.error('Failed to write Data.lua:', e.message);
    throw e;
  }
}

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