DLC (TH v1)

GLB D-League Challenge stat gatherer - Inspired by a legacy community script for GLB D-League stat collection. Original author unknown. Current version substantially rewritten.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         DLC (TH v1)
// @namespace    TruthHammer
// @description  GLB D-League Challenge stat gatherer - Inspired by a legacy community script for GLB D-League stat collection. Original author unknown. Current version substantially rewritten.
// @match        http://glb.warriorgeneral.com/game/home*
// @match        https://glb.warriorgeneral.com/game/home*
// @version      1.0.1
// @license      MIT
// @grant        none
// ==/UserScript==

// If anybody knows who the original author is, let me know, so I can add proper credit.

(function () {
  'use strict';

  // --- SEASON CONFIG (UPDATE THESE EACH SEASON FIRST) ---
  const playerList = [
    4906276,
    4907129,
    4906359,
    4908360,
    4908361,
    4907151,
    4908178,
    4908319,
    4907636,
    4906787,
    4906149,
    4907360,
    4907543,
    4906672,
    4906164,
    4907199,
    4907091,
    4906151,
    4906091,
    4906527,
    4906065,
    4906069,
    4906072,
    4906499,
    4908329,
    4908434,
  ];

  const season = 119;

  // --- END OF SEASON CONFIG ---

  // Change this to true to enable console logging for troubleshooting.
  const enableLogging = false;

  // Available stat types (selected in UI)
  const allStatTypes = [
    'Passing',
    'Rushing',
    'Receiving',
    'Kicking',
    'Punting',
    'Kick/Punt Return',
    'Special Teams',
    'Offensive Line',
    'Defense',
  ];
  let statTypes = allStatTypes.slice();

  // --- Config maps (editable per season/contest rules) ---
  const headings = {};
  headings['Passing'] = ['Plays','Comp','Att','Yds','Pct','Y/A','Hurry','Sack','SackYd','BadTh','Drop','Int','TD','Rating'];
  headings['Receiving'] = ['Plays','Targ','Rec','Yds','Avg','YAC','TD','Drop','TargRZ','Targ3d','Fum','FumL'];
  headings['Rushing'] = ['Plays','Rush','Yds','Avg','TD','BrTk','TFL','Fum','FumL'];
  headings['Kicking'] = ['FGM','FGA','0-19','20-29','30-39','40-49','50+','XPM','XPA','Points','KO','TB','Yds','Avg'];
  headings['Punting'] = ['Punts','Yds','Avg','TB','CofCrn','Inside5','Inside10','Inside20'];
  headings['Kick/Punt Return'] = ['KR','KYds','KAvg','KTD','PR','PYds','PAvg','PTD'];
  headings['Special Teams'] = ['Plays','Tk','MsTk','FFum','FRec','FumRTD','Pnkd','BrTk','Fum','FumL'];
  headings['Offensive Line'] = ['Plays','Pancakes','RevPnkd','HryAlw','SackAlw'];
  headings['Defense'] = [
    'Ply','Tk','MsTk','Sack','Hry','TFL','FFum','FumR','RecAlw','KL','PD','Int','DefTD'
  ];

  const sortable = {};
  sortable['Passing'] = 4;
  sortable['Rushing'] = 3;
  sortable['Receiving'] = 3;
  sortable['Kicking'] = 10;
  sortable['Punting'] = 2;
  sortable['Kick/Punt Return'] = 2;
  sortable['Special Teams'] = 2;
  sortable['Offensive Line'] = 2;
  sortable['Defense'] = 2;

  const points = {};
  points['Passing'] = [0,0,0,0.04,0,0,0,0,0,0,0,-1,4,0];
  points['Defense'] = [0,1,0,3,1,1,4,3,0,1,1,5,6]; // FS/SS/CB default from original
  points['Receiving'] = [0,0.5,0.1,0,6,0,0,0,0,0,0,0];
  points['Rushing'] = [0,0,0.4,0,6,0.5,0,0,0];
  points['Kicking'] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0];
  points['Punting'] = [0,0,0,0,0,0,0,0];
  points['Special Teams'] = [0,0,0,0,0,0,0,0,0,0];
  points['Offensive Line'] = [0,1.25,-1.25,-0.5,-4];
  points['Kick/Punt Return'] = [0,0,0,4,0,0,0,4];

  // Logger-only ignore list for known extra columns you intentionally do not track.
  const ignoredUnknownHeadersByType = {
    'Defense': ['SYds', 'IYds', 'Targ', 'Pnkd', 'RevPnk'],
    'Rushing': ['RushRZ'],
  };

  // --- STATE ---
  const playerSet = new Set(playerList.map(String));
  const baseUrl = `${location.protocol}//${location.host}`;

  let gamelinks = [];
  let players = {}; // map pid -> player object
  let playerAgentNamesById = {};
  let playerAgentUserIdsById = {};
  let rowCountsByType = {};
  let unknownHeadersByType = {};
  let lastCollectedWeek = null;
  let seasonMismatchWarned = false;
  let collectionCancelRequested = false;
  let activeCollectionAbortController = null;
  let outputNameFilter = '';
  let outputAgentFilter = '';
  let outputShowEmptySections = false;
  let outputSelectedStatTypes = new Set(allStatTypes);
  let outputActiveTab = 'tables';
  let outputCollapsedTypes = new Set();
  const RANK_TIE_EPSILON = 1e-9;
  let pointsConfigWarningsLogged = false;
  let seasonTotalWarningShown = false;

  function log(...args) {
    if (enableLogging) console.log(...args);
  }

  function warn(...args) {
    if (enableLogging) console.warn(...args);
  }

  function err(...args) {
    if (enableLogging) console.error(...args);
  }

  function fatal(...args) {
    console.error(...args);
  }

  // Boot UI
  window.setTimeout(() => {
    injectControls();
  }, 1000);

  function ensureUiStyles() {
    if (document.getElementById('dlc_v2_styles')) return;
    const style = document.createElement('style');
    style.id = 'dlc_v2_styles';
    style.textContent = `
#dlc_v2_controls {
  background: #f7f7f7;
  border: 1px solid #cfcfcf;
  border-radius: 4px;
  padding: 8px !important;
  margin-top: 6px;
}
#dlc_v2_controls input[type="button"] {
  margin-right: 2px;
}
#dlc_output_root {
  margin-top: 12px;
}
.dlc-summary {
  background: #f5f8ff;
  border: 1px solid #cfd8ea;
  border-radius: 4px;
  padding: 6px 8px;
  font-weight: bold;
}
.dlc-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 12px;
  align-items: center;
  margin: 8px 0 6px;
  padding: 6px 8px;
  border: 1px solid #d7d7d7;
  border-radius: 4px;
  background: #fbfbfb;
}
.dlc-toolbar label {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 12px;
}
.dlc-toolbar input[type="text"] {
  width: 180px;
  padding: 2px 4px;
}
.dlc-tabs {
  margin-top: 8px;
}
.dlc-tab-buttons {
  display: flex;
  gap: 4px;
  margin-bottom: 0;
}
.dlc-tab-btn {
  border: 1px solid #c8c8c8;
  background: #f3f3f3;
  padding: 4px 8px;
  cursor: pointer;
  border-radius: 4px 4px 0 0;
}
.dlc-tab-btn.active {
  background: #fff;
  border-bottom-color: #fff;
  font-weight: bold;
}
.dlc-tab-panel {
  border: 1px solid #d7d7d7;
  background: #fff;
  padding: 8px;
}
.dlc-forum-placeholder {
  color: #555;
  font-size: 12px;
}
.dlc-forum-tools {
  display: flex;
  gap: 6px;
  align-items: center;
  margin-bottom: 6px;
}
.dlc-forum-tools span {
  font-size: 12px;
  color: #555;
}
.dlc-forum-text {
  width: 100%;
  min-height: 260px;
  box-sizing: border-box;
  font-family: Arial, sans-serif;
  font-size: 12px;
}
.dlc-type-section {
  margin: 12px 0 16px;
  border: 1px solid #d7d7d7;
  border-radius: 4px;
  background: #fff;
}
.dlc-type-header {
  padding: 7px 9px;
  background: #eef2f7;
  border-bottom: 1px solid #d7d7d7;
  font-weight: bold;
  cursor: pointer;
  user-select: none;
}
.dlc-type-section:not([open]) .dlc-type-header {
  border-bottom: none;
}
.dlc-empty-note {
  padding: 8px 10px;
  color: #666;
  font-style: italic;
}
.dlc-table-wrap {
  overflow-x: auto;
  max-width: 100%;
}
.dlc-stats-table {
  width: 100%;
  border-collapse: collapse;
  margin: 0;
  font-size: 12px;
}
.dlc-stats-table th,
.dlc-stats-table td {
  border: 1px solid #dcdcdc;
  padding: 4px 6px;
  white-space: nowrap;
}
.dlc-stats-table thead th {
  position: sticky;
  top: 0;
  background: #f0f0f0;
  z-index: 1;
}
.dlc-stats-table tbody tr:nth-child(even) {
  background: #fafafa;
}
.dlc-stats-table tbody tr:hover {
  background: #eef7ff;
}
.dlc-stats-table td:nth-child(1),
.dlc-stats-table td:nth-child(2),
.dlc-stats-table td:nth-child(3),
.dlc-stats-table td:nth-child(4) {
  text-align: left;
}
.dlc-stats-table td {
  text-align: right;
}
`;
    document.head.appendChild(style);
  }

  function injectControls() {
    if (document.getElementById('dlc_v2_controls')) return;
    ensureUiStyles();
    const seasonInfo = detectSeasonInfoFromPage();

    const div = document.createElement('div');
    div.id = 'dlc_v2_controls';
    div.style.clear = 'both';
    div.style.padding = '6px 0';

    if (Number.isFinite(seasonInfo.day) && seasonInfo.day < 0) {
      const note = document.createElement('div');
      note.style.padding = '4px 0';
      note.textContent =
        'DLC collection will be available after the season starts. Before running this season, update the script config at the top: season and playerList.';
      div.appendChild(note);

      const content = document.getElementById('content') || document.body;
      content.parentNode.insertBefore(div, content.nextSibling);
      warnIfSeasonMismatch();
      return;
    }

    const btn = document.createElement('input');
    btn.value = 'Collect';
    btn.type = 'button';
    btn.id = 'dlc_collect';
    btn.addEventListener('click', collect, false);
    div.appendChild(btn);

    const csvBtn = document.createElement('input');
    csvBtn.value = 'Export CSV';
    csvBtn.type = 'button';
    csvBtn.id = 'dlc_export_csv';
    csvBtn.disabled = true;
    csvBtn.style.marginLeft = '6px';
    csvBtn.addEventListener('click', exportCsv, false);
    div.appendChild(csvBtn);

    const weekText = document.createElement('span');
    weekText.textContent = ' Week: ';
    div.appendChild(weekText);

    const weekSelect = document.createElement('select');
    weekSelect.id = 'week_input';
    weekSelect.style.minWidth = '56px';
    populateWeekOptions(weekSelect, seasonInfo);
    weekSelect.addEventListener('change', () => {
      const collectBtn = document.getElementById('dlc_collect');
      if (collectBtn) collectBtn.disabled = false;
    });
    div.appendChild(weekSelect);

    const hint = document.createElement('span');
    hint.style.marginLeft = '10px';
    hint.style.opacity = '0.8';
    hint.textContent = `Season ${season} - ${playerList.length} Players Configured`;
    div.appendChild(hint);

    const modeRow = document.createElement('div');
    modeRow.style.marginTop = '6px';
    modeRow.style.display = 'flex';
    modeRow.style.flexWrap = 'wrap';
    modeRow.style.gap = '10px';
    modeRow.style.alignItems = 'center';

    const modeLabel = document.createElement('span');
    modeLabel.textContent = 'Collection scope:';
    modeLabel.style.fontSize = '12px';
    modeRow.appendChild(modeLabel);

    const singleLabel = document.createElement('label');
    singleLabel.style.display = 'inline-flex';
    singleLabel.style.alignItems = 'center';
    singleLabel.style.gap = '3px';
    singleLabel.style.fontSize = '12px';
    const singleRadio = document.createElement('input');
    singleRadio.type = 'radio';
    singleRadio.name = 'dlc_week_mode';
    singleRadio.id = 'dlc_week_mode_single';
    singleRadio.value = 'single';
    singleRadio.checked = true;
    singleRadio.addEventListener('change', onCollectionModeChanged, false);
    singleLabel.appendChild(singleRadio);
    singleLabel.appendChild(document.createTextNode('Selected week only'));
    modeRow.appendChild(singleLabel);

    const throughLabel = document.createElement('label');
    throughLabel.style.display = 'inline-flex';
    throughLabel.style.alignItems = 'center';
    throughLabel.style.gap = '3px';
    throughLabel.style.fontSize = '12px';
    const throughRadio = document.createElement('input');
    throughRadio.type = 'radio';
    throughRadio.name = 'dlc_week_mode';
    throughRadio.id = 'dlc_week_mode_through';
    throughRadio.value = 'through';
    throughRadio.addEventListener('change', onSeasonTotalModeChanged, false);
    throughLabel.appendChild(throughRadio);
    throughLabel.appendChild(document.createTextNode('Season total through the selected week'));
    modeRow.appendChild(throughLabel);

    div.appendChild(modeRow);

    const progressWrap = document.createElement('div');
    progressWrap.id = 'dlc_progress_wrap';
    progressWrap.style.marginTop = '6px';
    progressWrap.style.maxWidth = '560px';

    const progressTopRow = document.createElement('div');
    progressTopRow.style.display = 'flex';
    progressTopRow.style.alignItems = 'center';
    progressTopRow.style.gap = '6px';
    progressTopRow.style.marginBottom = '3px';

    const cancelBtn = document.createElement('input');
    cancelBtn.value = 'Cancel';
    cancelBtn.type = 'button';
    cancelBtn.id = 'dlc_cancel';
    cancelBtn.disabled = true;
    cancelBtn.addEventListener('click', cancelCollection, false);
    progressTopRow.appendChild(cancelBtn);

    const progressText = document.createElement('div');
    progressText.id = 'dlc_progress_text';
    progressText.style.fontSize = '12px';
    progressText.style.opacity = '0.85';
    progressText.textContent = 'Idle';
    progressText.style.margin = '0';
    progressTopRow.appendChild(progressText);
    progressWrap.appendChild(progressTopRow);

    const progressTrack = document.createElement('div');
    progressTrack.style.height = '10px';
    progressTrack.style.border = '1px solid #999';
    progressTrack.style.background = '#eee';
    progressTrack.style.position = 'relative';

    const progressBar = document.createElement('div');
    progressBar.id = 'dlc_progress_bar';
    progressBar.style.height = '100%';
    progressBar.style.width = '0%';
    progressBar.style.background = '#5a9';
    progressBar.style.transition = 'width 120ms linear';
    progressTrack.appendChild(progressBar);

    progressWrap.appendChild(progressTrack);
    div.appendChild(progressWrap);

    const content = document.getElementById('content') || document.body;
    content.parentNode.insertBefore(div, content.nextSibling);

    warnIfSeasonMismatch();
  }

  async function collect() {
    const btn = document.getElementById('dlc_collect');
    const cancelBtn = document.getElementById('dlc_cancel');
    const weekSelect = document.getElementById('week_input');
    let completedSuccessfully = false;
    btn.disabled = true;
    if (cancelBtn) cancelBtn.disabled = false;
    if (weekSelect) weekSelect.disabled = true;
    setCollectionModeInputsDisabled(true);

    try {
      collectionCancelRequested = false;
      activeCollectionAbortController = new AbortController();
      statTypes = allStatTypes.slice();

      players = {};
      gamelinks = [];
      playerAgentNamesById = {};
      playerAgentUserIdsById = {};
      rowCountsByType = {};
      unknownHeadersByType = {};
      setOutputButtonsEnabled(false);
      setProgressUi(0, 'Starting collection...');
      for (const type of statTypes) rowCountsByType[type] = 0;
      logPointsConfigWarnings();

      const weekRaw = (document.getElementById('week_input') || {}).value;
      const week = parseInt(weekRaw, 10);
      if (!Number.isFinite(week) || week < 1 || week > 19) {
        alert('Enter a week number from 1 to 19');
        return;
      }
      lastCollectedWeek = week;
      const weeksToCollect = getWeeksToCollect(week);
      const weekMode = (weeksToCollect.length > 1) ? 'through' : 'single';

      log('[DLC v2] Collecting...', { season, week, weeksToCollect, weekMode, statTypes });

      // 1) Get unique game links for configured players
      const gameLinkSet = new Set();
      const totalLogJobs = Math.max(1, playerList.length);
      let logJobIndex = 0;
      for (let i = 0; i < playerList.length; i++) {
        const playerId = playerList[i];
        throwIfCollectionCancelled();
        logJobIndex += 1;
        setProgressUi(
          (logJobIndex / totalLogJobs) * 0.5,
          `Loading game logs (${logJobIndex}/${totalLogJobs})...`
        );
        const links = await getGameLinksForPlayer(playerId, weeksToCollect);
        throwIfCollectionCancelled();
        for (const l of links) gameLinkSet.add(l);
      }
      gamelinks = [...gameLinkSet];
      log('[DLC v2] game links:', gamelinks.length);
      setProgressUi(0.5, `Found ${gamelinks.length} game links. Loading boxscores...`);

      // 2) Harvest each game
      for (let i = 0; i < gamelinks.length; i++) {
        throwIfCollectionCancelled();
        const g = gamelinks[i];
        const frac = gamelinks.length ? 0.5 + (((i + 1) / gamelinks.length) * 0.5) : 1;
        setProgressUi(frac, `Harvesting boxscores (${i + 1}/${gamelinks.length})...`);
        await harvestGame(g);
      }

      const playerCount = Object.keys(players).length;
      log('[DLC v2] players:', playerCount, players);
      log('[DLC v2] players collected count:', playerCount);
      for (const type of statTypes) {
        log(`[DLC v2] rows gathered [${type}]:`, rowCountsByType[type] || 0);
        const unknown = unknownHeadersByType[type];
        if (unknown && unknown.length) {
          warn(`[DLC v2] unknown headers [${type}]:`, unknown);
        }
      }
      window.DLCv2 = {
        players, gamelinks, statTypes, headings, points, sortable,
        allStatTypes, rowCountsByType, unknownHeadersByType, playerAgentNamesById, playerAgentUserIdsById, season, weeksToCollect
      };
      setOutputButtonsEnabled(true);
      renderOutputTables();
      setProgressUi(1, `Done. ${playerCount} players collected from ${gamelinks.length} games.`);
      completedSuccessfully = true;

    } catch (e) {
      if (isCollectionCancelledError(e)) {
        warn('[DLC v2] Collection cancelled');
        setProgressUi(0, 'Collection cancelled.');
        return;
      }
      fatal('[DLC v2] Failed:', e);
      setProgressUi(0, 'Collection failed. Check console.');
      alert('Collection failed. Check browser console for details.');
    } finally {
      activeCollectionAbortController = null;
      collectionCancelRequested = false;
      btn.disabled = completedSuccessfully;
      if (cancelBtn) cancelBtn.disabled = true;
      if (weekSelect) weekSelect.disabled = false;
      setCollectionModeInputsDisabled(false);
    }
  }

  function cancelCollection() {
    collectionCancelRequested = true;
    if (activeCollectionAbortController) activeCollectionAbortController.abort();
    setProgressUi(0, 'Cancelling...');
    const cancelBtn = document.getElementById('dlc_cancel');
    if (cancelBtn) cancelBtn.disabled = true;
  }

  function throwIfCollectionCancelled() {
    if (collectionCancelRequested) {
      const err = new Error('DLC_COLLECTION_CANCELLED');
      err.name = 'DlcCollectionCancelled';
      throw err;
    }
  }

  function isCollectionCancelledError(e) {
    return !!e && (
      e.name === 'DlcCollectionCancelled' ||
      e.message === 'DLC_COLLECTION_CANCELLED' ||
      e.name === 'AbortError'
    );
  }

  function onCollectionModeChanged() {
    const collectBtn = document.getElementById('dlc_collect');
    if (collectBtn) collectBtn.disabled = false;
  }

  function onSeasonTotalModeChanged(e) {
    onCollectionModeChanged();
    if (!e || !e.target || !e.target.checked) return;
    if (seasonTotalWarningShown) return;
    seasonTotalWarningShown = true;
    alert("Collecting season total stats makes more requests to GLB servers - especially late in the season. Be responsible and do not spam the 'Collect' button.");
  }

  function setCollectionModeInputsDisabled(disabled) {
    const radios = document.querySelectorAll('input[name="dlc_week_mode"]');
    for (const r of radios) r.disabled = !!disabled;
  }

  function getWeeksToCollect(selectedWeek) {
    const through = !!document.getElementById('dlc_week_mode_through')?.checked;
    if (!through) return [selectedWeek];
    const weeks = [];
    for (let w = 1; w <= selectedWeek; w++) weeks.push(w);
    return weeks;
  }

  async function getGameLinksForPlayer(playerId, weeks) {
    const url = `${baseUrl}/game/player_game_log.pl?player_id=${playerId}&season=${season}&stat_type=raw`;
    const doc = await fetchDoc(url);

    const pid = String(playerId);
    if (!playerAgentNamesById[pid]) {
      try {
        const agentInfo = getAgentInfoFromDoc(doc, playerId);
        if (agentInfo.name) playerAgentNamesById[pid] = agentInfo.name;
        if (agentInfo.userId) playerAgentUserIdsById[pid] = agentInfo.userId;
      } catch (e) {
        warn('[DLC v2] agent lookup failed from game log for player', playerId, e);
      }
    }

    return parseGameLogLinks(doc, weeks);
  }

  function getAgentInfoFromDoc(doc, playerId) {
    const rows = Array.from(doc.querySelectorAll('tr'));
    for (const tr of rows) {
      const headCell = tr.querySelector('td.vital_head');
      if (!headCell) continue;
      const label = (headCell.textContent || '').trim();
      if (!/^Agent:\s*$/i.test(label)) continue;

      const dataCell = tr.querySelector('td.vital_data');
      const agentLink = dataCell ? dataCell.querySelector('a[href*="user_id="]') : null;
      const name = agentLink ? (agentLink.textContent || '').trim() : (dataCell ? (dataCell.textContent || '').trim() : '');
      const href = agentLink ? agentLink.getAttribute('href') : '';
      const m = (href || '').match(/user_id=(\d+)/);
      return {
        name: name || '',
        userId: m ? m[1] : null,
      };
    }

    warn('[DLC v2] Agent row not found on game log page', { playerId });
    return { name: '', userId: null };
  }

  function logPointsConfigWarnings() {
    if (pointsConfigWarningsLogged) return;
    pointsConfigWarningsLogged = true;
    for (const type of allStatTypes) {
      const cols = headings[type] || [];
      const pts = points[type];
      if (!Array.isArray(pts)) {
        warn(`[DLC v2] points config missing for [${type}]`);
        continue;
      }
      if (pts.length !== cols.length) {
        warn(`[DLC v2] points/headings length mismatch for [${type}]`, {
          headings: cols.length,
          points: pts.length
        });
      }
    }
  }

  function setOutputButtonsEnabled(enabled) {
    const csvBtn = document.getElementById('dlc_export_csv');
    if (csvBtn) csvBtn.disabled = !enabled;
  }

  function setProgressUi(fraction, message) {
    const bar = document.getElementById('dlc_progress_bar');
    const text = document.getElementById('dlc_progress_text');
    const clamped = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
    if (bar) bar.style.width = `${Math.round(clamped * 100)}%`;
    if (text) text.textContent = message || `${Math.round(clamped * 100)}%`;
  }

  function recordUnknownHeader(type, rawName, normalizedName) {
    if (!type) return;
    const ignored = ignoredUnknownHeadersByType[type];
    if (Array.isArray(ignored) && ignored.includes(normalizedName)) return;
    if (!unknownHeadersByType[type]) unknownHeadersByType[type] = [];
    const key = `${String(rawName)} => ${String(normalizedName)}`;
    if (!unknownHeadersByType[type].includes(key)) {
      unknownHeadersByType[type].push(key);
    }
  }

  function parseGameLogLinks(doc, weeks) {
    const tables = Array.from(doc.querySelectorAll('table'));
    let logTable = null;
    const weekList = Array.isArray(weeks) ? weeks : [weeks];
    const weekSet = new Set(
      weekList
        .map((w) => parseInt(w, 10))
        .filter((w) => Number.isFinite(w))
    );

    // Prefer a table with a header containing "Week"
    for (const t of tables) {
      const firstRow = t.querySelector('tr');
      if (!firstRow) continue;
      const headers = Array.from(firstRow.querySelectorAll('th,td'))
        .map(x => (x.textContent || '').trim());
      if (headers.some(h => /week/i.test(h))) {
        logTable = t;
        break;
      }
    }
    // Fallback: old script used tables[1]
    if (!logTable && tables.length >= 2) logTable = tables[1];
    if (!logTable) {
      warn('[DLC v2] Game log table not found', { weeks: Array.from(weekSet) });
      return [];
    }

    const links = [];
    const rows = Array.from(logTable.querySelectorAll('tr')).slice(1);

    for (const tr of rows) {
      const tds = tr.querySelectorAll('td');
      if (!tds || tds.length < 4) continue;

      const wk = parseInt((tds[0].textContent || '').trim(), 10);
      if (!weekSet.has(wk)) continue;

      const a = tds[3].querySelector('a[href*="game_id="]') || tr.querySelector('a[href*="game_id="]');
      if (!a) continue;

      links.push(absUrl(a.getAttribute('href')));
    }

    log('[DLC v2] Game log links parsed', {
      weeks: Array.from(weekSet),
      rowsScanned: rows.length,
      linksFound: links.length
    });
    return links;
  }

  async function harvestGame(gameUrl) {
    const doc = await fetchDoc(gameUrl);
    const tables = Array.from(doc.querySelectorAll('table'));

    // Look for [category label], then next two tables are teams
    for (let i = 0; i < tables.length - 2; i++) {
      const rawLabel = readCategoryLabel(tables[i]);
      const cat = normalizeCategoryLabel(rawLabel);
      if (!cat) continue;
      log('[DLC v2] Category label found', { raw: rawLabel, normalized: cat, gameUrl });
      if (!statTypes.includes(cat)) {
        log('[DLC v2] Category skipped (not enabled for harvest)', { cat, gameUrl });
        continue;
      }

      const team1 = tables[i + 1];
      const team2 = tables[i + 2];
      if (!looksLikeStatTeamTable(team1) || !looksLikeStatTeamTable(team2)) {
        warn('[DLC v2] Category matched but team stat tables not detected', { cat, gameUrl });
        continue;
      }

      const name1 = readTeamName(team1) || 'Team 1';
      const name2 = readTeamName(team2) || 'Team 2';

      loadPlayersFromTeamTable(team1, cat, name1);
      loadPlayersFromTeamTable(team2, cat, name2);

      i += 2;
    }
  }

  function readCategoryLabel(table) {
    const td = table.querySelector('td');
    if (!td) return null;
    const text = (td.textContent || '').trim();
    if (!text || text.length > 80) return null;
    return text;
  }

  function readTeamName(table) {
    const th = table.querySelector('th');
    return th ? (th.textContent || '').trim() : null;
  }

  function normalizeCategoryLabel(label) {
    if (!label) return null;
    const text = label.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
    const aliases = {
      'Kick Punt Return': 'Kick/Punt Return',
      'Kick/Punt Returns': 'Kick/Punt Return',
      'Returns': 'Kick/Punt Return',
      'Special Team': 'Special Teams',
    };
    return aliases[text] || text;
  }

  function looksLikeStatTeamTable(table) {
    if (!table) return false;
    const rows = Array.from(table.querySelectorAll('tr'));
    if (rows.length < 2) return false;
    if (!table.querySelector('a[href*="player_id="]')) return false;
    return rows.some(r => r.querySelectorAll('th,td').length > 1);
  }

  function normalizeStatName(rawName, type, currentStats) {
    let statName = (rawName || '').replace(/\u00a0/g, ' ').replace(/\s+/g, '');
    const aliases = {
      'RecAllowed': 'RecAlw',
      'RecAlwd': 'RecAlw',
      'RecAlw.': 'RecAlw',
      'RevPnkd': 'RevPnkd',
      'RevPnk': 'RevPnk',
      'FRec': 'FRec',
      'FumRec': 'FRec',
      'FumRTD': 'FumRTD',
    };
    if (aliases[statName]) statName = aliases[statName];

    // Preserve original category-specific disambiguation for duplicate column labels.
    if (type === 'Kick/Punt Return') {
      if (statName === 'Yds') statName = (currentStats.KYds == null) ? 'KYds' : 'PYds';
      if (statName === 'Avg') statName = (currentStats.KAvg == null) ? 'KAvg' : 'PAvg';
      if (statName === 'TD') statName = (currentStats.KTD == null) ? 'KTD' : 'PTD';
    }
    if (type === 'Defense' && statName === 'Yds') {
      statName = (currentStats.SYds == null) ? 'SYds' : 'IYds';
    }
    return statName;
  }

  function loadPlayersFromTeamTable(table, type, teamName) {
    const rows = Array.from(table.querySelectorAll('tr'));
    if (rows.length < 2) return;

    // GLB tables usually have a team-name row first, then the actual stat header row.
    const headerRowIndex = rows.findIndex((row) => {
      const cells = row.querySelectorAll('th,td');
      return cells.length > 1 && !row.querySelector('a[href*="player_id="]');
    });
    if (headerRowIndex < 0) {
      warn('[DLC v2] Header row not found in team stat table', { type, teamName });
      return;
    }

    const headerCells = Array.from(rows[headerRowIndex].querySelectorAll('th,td'));
    const colNames = headerCells.map(c => (c.textContent || '').trim());

    let trackedPlayersFound = 0;
    let untrackedPlayersSeen = 0;
    for (let i = headerRowIndex + 1; i < rows.length; i++) {
      const r = rows[i];
      const cells = Array.from(r.querySelectorAll('td'));
      if (!cells.length) continue;

      const a = cells[0].querySelector('a[href*="player_id="]');
      if (!a) continue;

      const pid = extractPlayerId(a.getAttribute('href'));
      if (!pid) continue;
      if (!playerSet.has(pid)) {
        untrackedPlayersSeen += 1;
        continue;
      }
      trackedPlayersFound += 1;

      const pname = (a.textContent || '').trim();
      const ppos = (cells[0].childNodes[0] && cells[0].childNodes[0].textContent)
        ? cells[0].childNodes[0].textContent.trim()
        : '';

      if (!players[pid]) {
        players[pid] = {
          id: pid,
          name: pname,
          pos: ppos,
          tname: (teamName || '').trim(),
          stats: {}
        };
        for (const st of statTypes) players[pid].stats[st] = [];
      }

      const s = {};
      const expectedHeaders = headings[type] || [];
      for (let j = 1; j < cells.length && j < colNames.length; j++) {
        const rawStatName = colNames[j];
        if (!rawStatName) continue;
        let statName = normalizeStatName(rawStatName, type, s);
        let value = (cells[j].textContent || '').trim().replace(/,/g, '');

        if (expectedHeaders.length && !expectedHeaders.includes(statName)) {
          recordUnknownHeader(type, rawStatName, statName);
        }

        // preserve original special-casing
        if (statName === 'SackYd') {
          const f = parseFloat(value);
          value = Number.isFinite(f) ? Math.round(f).toString() : '0';
        }
        s[statName] = value;
      }

      players[pid].stats[type].push(s);
      rowCountsByType[type] = (rowCountsByType[type] || 0) + 1;
    }

    if (trackedPlayersFound === 0) {
      log('[DLC v2] No tracked players found in matched team stat table', {
        type,
        teamName,
        untrackedPlayersSeen
      });
    }
  }

  function extractPlayerId(href) {
    const m = (href || '').match(/player_id=(\d+)/);
    return m ? m[1] : null;
  }

  function absUrl(href) {
    if (!href) return null;
    if (href.startsWith('http://') || href.startsWith('https://')) return href;
    if (href.startsWith('/')) return baseUrl + href;
    return baseUrl + '/' + href;
  }

  function num(v) {
    const n = parseFloat(v);
    return Number.isFinite(n) ? n : 0;
  }

  function fmt(v) {
    if (typeof v === 'number') {
      return Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.00$/, '');
    }
    return (v == null) ? '' : String(v);
  }

  function aggregateLines(type, lines) {
    if (!Array.isArray(lines) || !lines.length) return null;
    const cols = headings[type] || [];
    const totals = {};
    for (const c of cols) totals[c] = 0;

    for (const row of lines) {
      if (!row) continue;
      for (const key of Object.keys(row)) {
        if (totals[key] == null) continue;
        totals[key] += num(row[key]);
      }
    }

    // Recompute common derived values after summing raw rows.
    if (type === 'Rushing' && totals.Rush > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.Rush;
    if (type === 'Receiving') {
      if (totals.Rec > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.Rec;
    }
    if (type === 'Kick/Punt Return') {
      if (totals.KR > 0 && totals.KAvg != null) totals.KAvg = totals.KYds / totals.KR;
      if (totals.PR > 0 && totals.PAvg != null) totals.PAvg = totals.PYds / totals.PR;
    }
    if (type === 'Punting' && totals.Punts > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.Punts;
    if (type === 'Kicking' && totals.KO > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.KO;
    if (type === 'Passing') {
      if (totals.Att > 0) {
        if (totals.Pct != null) totals.Pct = parseFloat(((totals.Comp / totals.Att) * 100).toFixed(1));
        if (totals['Y/A'] != null) totals['Y/A'] = parseFloat((totals.Yds / totals.Att).toFixed(1));
        if (totals.Rating != null) {
          const a = Math.max(0, Math.min(2.375, ((totals.Comp / totals.Att) - 0.3) * 5));
          const b = Math.max(0, Math.min(2.375, ((totals.Yds / totals.Att) - 3) * 0.25));
          const c = Math.max(0, Math.min(2.375, (totals.TD / totals.Att) * 20));
          const d = Math.max(0, Math.min(2.375, 2.375 - ((totals.Int / totals.Att) * 25)));
          totals.Rating = parseFloat((100 * (a + b + c + d) / 6).toFixed(1));
        }
      } else {
        if (totals.Pct != null) totals.Pct = 0.0;
        if (totals['Y/A'] != null) totals['Y/A'] = 0.0;
        if (totals.Rating != null) totals.Rating = 0.0;
      }
    }

    return totals;
  }

  function applyAggregateAdjustments(aggStats, pid) {
    const passing = aggStats['Passing'];
    const rushing = aggStats['Rushing'];
    if (passing && rushing) {
      if (rushing.Rush != null) rushing.Rush -= num(passing.Sack);
      if (rushing.Yds != null) rushing.Yds -= num(passing.SackYd);
      if (num(rushing.Rush) <= 0) {
        log('[DLC v2] Rushing aggregate removed after sack adjustment', { pid });
        aggStats['Rushing'] = null;
      } else if (rushing.Avg != null) {
        rushing.Avg = num(rushing.Yds) / num(rushing.Rush);
      }
    }

    const st = aggStats['Special Teams'];
    const def = aggStats['Defense'];
    if (st && def) {
      if (def.Tk != null) def.Tk -= num(st.Tk);
      if (def.MsTk != null) def.MsTk -= num(st.MsTk);
      if (def.FFum != null) def.FFum -= num(st.FFum);
      if (def.FRec != null) def.FRec -= num(st.FRec);

      let sum = 0;
      for (const s of (headings['Defense'] || [])) {
        if (s === 'Ply') continue;
        sum += num(def[s]);
      }
      if (sum === 0) {
        log('[DLC v2] Defense aggregate removed after Special Teams subtraction', { pid });
        aggStats['Defense'] = null;
      }
    }
  }

  function computePoints(type, totals) {
    if (!totals) return null;
    const pts = points[type];
    const cols = headings[type] || [];
    if (!Array.isArray(pts) || !pts.length) return null;
    let total = 0;
    for (let i = 0; i < cols.length; i++) {
      total += num(pts[i]) * num(totals[cols[i]]);
    }
    return total;
  }

  function buildAggregates() {
    const rows = [];
    for (const pid of Object.keys(players)) {
      const p = players[pid];
      const aggStats = {};
      for (const type of statTypes) {
        aggStats[type] = aggregateLines(type, p.stats[type]);
      }
      applyAggregateAdjustments(aggStats, pid);
      rows.push({
        pid,
        name: p.name || '',
        pos: p.pos || '',
        tname: p.tname || '',
        agent: playerAgentNamesById[pid] || '',
        stats: aggStats,
      });
    }
    rows.sort((a, b) => a.name.localeCompare(b.name));
    return rows;
  }

  function getOrCreateOutputRoot() {
    let root = document.getElementById('dlc_output_root');
    if (!root) {
      root = document.createElement('div');
      root.id = 'dlc_output_root';
      root.style.marginTop = '10px';
      const controls = document.getElementById('dlc_v2_controls');
      const anchor = controls || (document.getElementById('content') || document.body);
      anchor.parentNode.insertBefore(root, anchor.nextSibling);
    }
    return root;
  }

  function renderOutputTables() {
    const agg = buildAggregates();
    const root = getOrCreateOutputRoot();
    root.innerHTML = '';

    const summary = document.createElement('div');
    summary.className = 'dlc-summary';
    summary.textContent = `Week ${lastCollectedWeek ?? '?'} • ${agg.length} players • ${gamelinks.length} games`;
    root.appendChild(summary);

    root.appendChild(createOutputToolbar());

    const nameFilterText = outputNameFilter.trim().toLowerCase();
    const agentFilterText = outputAgentFilter.trim().toLowerCase();
    const filteredAgg = agg.filter((p) => {
      if (nameFilterText && !(p.name || '').toLowerCase().includes(nameFilterText)) return false;
      if (agentFilterText && !(p.agent || '').toLowerCase().includes(agentFilterText)) return false;
      return true;
    });

    const tabs = createOutputTabs(filteredAgg);
    const tablesPanel = tabs.tablesPanel;

    if (tablesPanel) {
      const tableTools = document.createElement('div');
      tableTools.className = 'dlc-forum-tools';

      const collapseBtn = document.createElement('input');
      collapseBtn.type = 'button';
      collapseBtn.value = 'Collapse All';
      collapseBtn.addEventListener('click', () => {
        outputCollapsedTypes = new Set(allStatTypes);
        renderOutputTables();
      });
      tableTools.appendChild(collapseBtn);

      const expandBtn = document.createElement('input');
      expandBtn.type = 'button';
      expandBtn.value = 'Expand All';
      expandBtn.addEventListener('click', () => {
        outputCollapsedTypes = new Set();
        renderOutputTables();
      });
      tableTools.appendChild(expandBtn);

      tablesPanel.appendChild(tableTools);
    }

    for (const type of allStatTypes) {
      if (!outputSelectedStatTypes.has(type)) continue;
      const sectionRows = filteredAgg
        .map(p => ({ ...p, totals: p.stats[type], points: computePoints(type, p.stats[type]) }))
        .filter(p => p.totals);

      if (!outputShowEmptySections && sectionRows.length === 0) continue;

      const section = document.createElement('details');
      section.className = 'dlc-type-section';
      section.open = !outputCollapsedTypes.has(type);
      section.addEventListener('toggle', () => {
        if (section.open) outputCollapsedTypes.delete(type);
        else outputCollapsedTypes.add(type);
      });

      const title = document.createElement('summary');
      title.className = 'dlc-type-header';
      title.textContent = `${type} (${sectionRows.length})`;
      section.appendChild(title);

      if (sectionRows.length) {
        section.appendChild(createStatsTable(type, sectionRows));
      } else {
        const empty = document.createElement('div');
        empty.className = 'dlc-empty-note';
        empty.textContent = 'No matching players for current filter.';
        section.appendChild(empty);
      }
      if (tablesPanel) tablesPanel.appendChild(section);
    }

    root.appendChild(tabs.wrap);
  }

  function createOutputTabs(filteredAgg) {
    const wrap = document.createElement('div');
    wrap.className = 'dlc-tabs';

    const btns = document.createElement('div');
    btns.className = 'dlc-tab-buttons';

    const tablesBtn = document.createElement('button');
    tablesBtn.type = 'button';
    tablesBtn.className = `dlc-tab-btn${outputActiveTab === 'tables' ? ' active' : ''}`;
    tablesBtn.textContent = 'Tables';
    tablesBtn.addEventListener('click', () => {
      if (outputActiveTab !== 'tables') {
        outputActiveTab = 'tables';
        renderOutputTables();
      }
    });
    btns.appendChild(tablesBtn);

    const forumBtn = document.createElement('button');
    forumBtn.type = 'button';
    forumBtn.className = `dlc-tab-btn${outputActiveTab === 'forum' ? ' active' : ''}`;
    forumBtn.textContent = 'Forum';
    forumBtn.addEventListener('click', () => {
      if (outputActiveTab !== 'forum') {
        outputActiveTab = 'forum';
        renderOutputTables();
      }
    });
    btns.appendChild(forumBtn);

    const rankingsBtn = document.createElement('button');
    rankingsBtn.type = 'button';
    rankingsBtn.className = `dlc-tab-btn${outputActiveTab === 'rankings' ? ' active' : ''}`;
    rankingsBtn.textContent = 'Rankings';
    rankingsBtn.addEventListener('click', () => {
      if (outputActiveTab !== 'rankings') {
        outputActiveTab = 'rankings';
        renderOutputTables();
      }
    });
    btns.appendChild(rankingsBtn);
    wrap.appendChild(btns);

    const panel = document.createElement('div');
    panel.className = 'dlc-tab-panel';
    wrap.appendChild(panel);

    if (outputActiveTab === 'forum') {
      panel.appendChild(createForumPanel(filteredAgg));
      return { wrap, tablesPanel: null };
    }
    if (outputActiveTab === 'rankings') {
      panel.appendChild(createRankingsPanel(filteredAgg));
      return { wrap, tablesPanel: null };
    }

    return { wrap, tablesPanel: panel };
  }

  function createOutputToolbar() {
    const bar = document.createElement('div');
    bar.className = 'dlc-toolbar';

    const typesLabel = document.createElement('label');
    typesLabel.textContent = 'Stat Types';
    typesLabel.style.alignItems = 'flex-start';
    const typesWrap = document.createElement('span');
    typesWrap.style.display = 'inline-flex';
    typesWrap.style.flexWrap = 'wrap';
    typesWrap.style.gap = '4px 8px';
    typesWrap.style.maxWidth = '700px';
    for (const type of allStatTypes) {
      const tLabel = document.createElement('label');
      tLabel.style.display = 'inline-flex';
      tLabel.style.alignItems = 'center';
      tLabel.style.gap = '3px';
      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.checked = outputSelectedStatTypes.has(type);
      cb.addEventListener('change', (e) => {
        if (e.target.checked) outputSelectedStatTypes.add(type);
        else outputSelectedStatTypes.delete(type);
        renderOutputTables();
      });
      tLabel.appendChild(cb);
      tLabel.appendChild(document.createTextNode(type));
      typesWrap.appendChild(tLabel);
    }
    typesLabel.appendChild(typesWrap);
    bar.appendChild(typesLabel);

    const filterLabel = document.createElement('label');
    filterLabel.textContent = 'Filter Name';
    const filterInput = document.createElement('input');
    filterInput.type = 'text';
    filterInput.value = outputNameFilter;
    filterInput.placeholder = 'Player name...';
    filterInput.addEventListener('input', (e) => {
      outputNameFilter = e.target.value || '';
      renderOutputTables();
    });
    filterLabel.appendChild(filterInput);
    bar.appendChild(filterLabel);

    const agentLabel = document.createElement('label');
    agentLabel.textContent = 'Filter Agent';
    const agentInput = document.createElement('input');
    agentInput.type = 'text';
    agentInput.value = outputAgentFilter;
    agentInput.placeholder = 'Agent name...';
    agentInput.addEventListener('input', (e) => {
      outputAgentFilter = e.target.value || '';
      renderOutputTables();
    });
    agentLabel.appendChild(agentInput);
    bar.appendChild(agentLabel);

    const emptyLabel = document.createElement('label');
    const emptyCheckbox = document.createElement('input');
    emptyCheckbox.type = 'checkbox';
    emptyCheckbox.checked = outputShowEmptySections;
    emptyCheckbox.addEventListener('change', (e) => {
      outputShowEmptySections = !!e.target.checked;
      renderOutputTables();
    });
    emptyLabel.appendChild(emptyCheckbox);
    emptyLabel.appendChild(document.createTextNode('Show Empty Sections'));
    bar.appendChild(emptyLabel);

    const clearBtn = document.createElement('input');
    clearBtn.type = 'button';
    clearBtn.value = 'Clear Filter';
    clearBtn.addEventListener('click', () => {
      outputNameFilter = '';
      outputAgentFilter = '';
      renderOutputTables();
    });
    bar.appendChild(clearBtn);

    return bar;
  }

  function createForumPanel(filteredAgg) {
    const wrap = document.createElement('div');

    const tools = document.createElement('div');
    tools.className = 'dlc-forum-tools';

    const copyBtn = document.createElement('input');
    copyBtn.type = 'button';
    copyBtn.value = 'Copy Forum Text';
    tools.appendChild(copyBtn);

    const note = document.createElement('span');
    note.textContent = 'One-line rows, compact labels, zero-padded numeric columns, text at end.';
    tools.appendChild(note);
    wrap.appendChild(tools);

    const ta = document.createElement('textarea');
    ta.className = 'dlc-forum-text';
    ta.readOnly = true;
    ta.value = buildForumText(filteredAgg);
    wrap.appendChild(ta);

    copyBtn.addEventListener('click', async () => {
      const text = ta.value || '';
      try {
        if (navigator.clipboard && navigator.clipboard.writeText) {
          await navigator.clipboard.writeText(text);
        } else {
          ta.focus();
          ta.select();
          document.execCommand('copy');
        }
        copyBtn.value = 'Copied';
        window.setTimeout(() => { copyBtn.value = 'Copy Forum Text'; }, 1000);
      } catch (e) {
        copyBtn.value = 'Copy Failed';
        warn('[DLC v2] forum copy failed', e);
        window.setTimeout(() => { copyBtn.value = 'Copy Forum Text'; }, 1200);
      }
    });

    return wrap;
  }

  function createRankingsPanel(filteredAgg) {
    const wrap = document.createElement('div');

    const tools = document.createElement('div');
    tools.className = 'dlc-forum-tools';

    const copyBtn = document.createElement('input');
    copyBtn.type = 'button';
    copyBtn.value = 'Copy Rankings';
    tools.appendChild(copyBtn);

    const note = document.createElement('span');
    note.textContent = 'Forum-ready rankings across selected stat types.';
    tools.appendChild(note);
    wrap.appendChild(tools);

    const ta = document.createElement('textarea');
    ta.className = 'dlc-forum-text';
    ta.readOnly = true;
    ta.value = buildRankingsText(filteredAgg);
    wrap.appendChild(ta);

    copyBtn.addEventListener('click', async () => {
      const text = ta.value || '';
      try {
        if (navigator.clipboard && navigator.clipboard.writeText) {
          await navigator.clipboard.writeText(text);
        } else {
          ta.focus();
          ta.select();
          document.execCommand('copy');
        }
        copyBtn.value = 'Copied';
        window.setTimeout(() => { copyBtn.value = 'Copy Rankings'; }, 1000);
      } catch (e) {
        copyBtn.value = 'Copy Failed';
        warn('[DLC v2] rankings copy failed', e);
        window.setTimeout(() => { copyBtn.value = 'Copy Rankings'; }, 1200);
      }
    });

    return wrap;
  }

  function buildForumText(filteredAgg) {
    const lines = [];
    lines.push(`[b]DLC Season ${season} Week ${lastCollectedWeek ?? '?'}[/b]`);
    lines.push('');

    for (const type of allStatTypes) {
      if (!outputSelectedStatTypes.has(type)) continue;

      const sectionRows = filteredAgg
        .map(p => ({ ...p, totals: p.stats[type], points: computePoints(type, p.stats[type]) }))
        .filter(p => p.totals);
      if (!outputShowEmptySections && sectionRows.length === 0) continue;

      const forumRows = buildForumSectionRows(type, sectionRows);
      lines.push(`[b]${type} (${sectionRows.length})[/b]`);
      if (forumRows.length) {
        lines.push(...forumRows);
      } else {
        lines.push('[i]No matching players for current filter.[/i]');
      }
      lines.push('');
    }

    return lines.join('\n').trimEnd();
  }

  function buildRankingsText(filteredAgg) {
    const ranked = buildRankingsRows(filteredAgg);
    const lines = [];
    lines.push(`[b]DLC Rankings - Season ${season} Week ${lastCollectedWeek ?? '?'}[/b]`);

    const rankWidth = Math.max(1, ...ranked.map((r) => String(r.rank).length));
    const totalSpec = getRankingsTotalSpec(ranked);

    for (const row of ranked) {
      const rankText = String(row.rank).padStart(rankWidth, '0');
      const totalText = formatForumValue(row.totalPoints, totalSpec);
      lines.push(
        `${rankText} | ${totalText} | ${row.agent || ''} | ${row.name || ''} | ${row.team || ''}`
      );
    }

    return lines.join('\n').trimEnd();
  }

  function buildRankingsRows(filteredAgg) {
    const rows = filteredAgg.map((p) => {
      let totalPoints = 0;
      for (const type of allStatTypes) {
        if (!outputSelectedStatTypes.has(type)) continue;
        totalPoints += num(computePoints(type, p.stats[type]));
      }
      return {
        pid: p.pid,
        name: p.name || '',
        team: p.tname || '',
        agent: p.agent || '',
        totalPoints,
      };
    });

    rows.sort((a, b) => {
      if (Math.abs(b.totalPoints - a.totalPoints) > RANK_TIE_EPSILON) return b.totalPoints - a.totalPoints;
      return a.name.localeCompare(b.name);
    });

    let lastScore = null;
    let currentRank = 0;
    for (let i = 0; i < rows.length; i++) {
      const r = rows[i];
      if (lastScore == null || Math.abs(r.totalPoints - lastScore) > RANK_TIE_EPSILON) {
        currentRank = i + 1;
        lastScore = r.totalPoints;
      }
      r.rank = currentRank;
      r.totalPointsText = Number.isFinite(r.totalPoints) ? r.totalPoints.toFixed(2) : '0.00';
    }

    return rows;
  }

  function getRankingsTotalSpec(rows) {
    let maxIntDigits = 1;
    let maxFracDigits = 2;
    for (const r of rows) {
      const s = Number.isFinite(r.totalPoints) ? r.totalPoints.toFixed(2) : '0.00';
      const m = String(s).match(/^[-+]?(\d+)(?:\.(\d+))?$/);
      if (!m) continue;
      maxIntDigits = Math.max(maxIntDigits, m[1].length);
      maxFracDigits = Math.max(maxFracDigits, (m[2] || '').length);
    }
    return { maxIntDigits, maxFracDigits };
  }

  function buildForumSectionRows(type, sectionRows) {
    if (!sectionRows.length) return [];

    const allNumericCols = ['Pts', 'Games'].concat(headings[type] || []);
    const rows = sectionRows
      .map((p) => ({
        name: p.name || '',
        pos: p.pos || '',
        team: p.tname || '',
        nums: buildForumNumericMap(type, p),
      }))
      .sort((a, b) => {
        const ap = num(a.nums.Pts);
        const bp = num(b.nums.Pts);
        if (bp !== ap) return bp - ap;
        return a.name.localeCompare(b.name);
      });

    const numericCols = allNumericCols.filter((col) => {
      return rows.some((r) => num(r.nums[col]) !== 0);
    });

    const specs = {};
    for (const col of numericCols) {
      let maxIntDigits = 1;
      let maxFracDigits = 0;
      for (const r of rows) {
        const raw = r.nums[col];
        const s = fmt(raw);
        const m = String(s).match(/^[-+]?(\d+)(?:\.(\d+))?$/);
        if (!m) continue;
        maxIntDigits = Math.max(maxIntDigits, m[1].length);
        maxFracDigits = Math.max(maxFracDigits, (m[2] || '').length);
      }
      specs[col] = { maxIntDigits, maxFracDigits };
    }

    const out = [];
    for (const r of rows) {
      const parts = numericCols.map((col) => {
        const v = formatForumValue(r.nums[col], specs[col]);
        return `${v}${forumLabelForCol(col)}`;
      });
      out.push(`${parts.join(' ')} - ${r.name} | ${r.pos} | ${r.team}`);
    }
    return out;
  }

  function buildForumNumericMap(type, p) {
    const map = {};
    map.Pts = p.points == null ? 0 : p.points;
    map.Games = (players[p.pid] && players[p.pid].stats[type]) ? players[p.pid].stats[type].length : 0;
    for (const h of (headings[type] || [])) {
      map[h] = p.totals[h];
    }
    return map;
  }

  function formatForumValue(value, spec) {
    const base = fmt(value);
    const m = String(base).match(/^([-+]?)(\d+)(?:\.(\d+))?$/);
    if (!m || !spec) return String(base);
    const sign = m[1] || '';
    const intPart = (m[2] || '0').padStart(spec.maxIntDigits, '0');
    const fracRaw = m[3] || '';
    if (!spec.maxFracDigits) return `${sign}${intPart}`;
    const fracPart = fracRaw.padEnd(spec.maxFracDigits, '0');
    return `${sign}${intPart}.${fracPart}`;
  }

  function forumLabelForCol(col) {
    if (col === 'Games') return 'g';
    if (col === 'Pts') return 'pts';
    if (col === 'Rating') return 'rtg';
    if (col === 'Yds') return 'yds';
    return String(col).toLowerCase();
  }

  function createStatsTable(type, rowsForType) {
    const wrap = document.createElement('div');
    wrap.className = 'dlc-table-wrap';

    const table = document.createElement('table');
    table.className = 'dlc-stats-table';
    table.dataset.sortDir = 'desc';
    table.dataset.sortCol = '';

    const cols = ['Name', 'Pos', 'Team', 'Agent', 'Games'].concat(headings[type] || []).concat(['Pts']);

    const thead = document.createElement('thead');
    const hr = document.createElement('tr');
    cols.forEach((c, idx) => {
      const th = document.createElement('th');
      th.textContent = c;
      th.style.cursor = 'pointer';
      th.dataset.colIndex = String(idx);
      th.addEventListener('click', () => sortOutputTable(table, idx));
      hr.appendChild(th);
    });
    thead.appendChild(hr);
    table.appendChild(thead);

    const tbody = document.createElement('tbody');
    for (const p of rowsForType) {
      const tr = document.createElement('tr');
      const vals = [
        p.name,
        p.pos,
        p.tname,
        p.agent,
        (players[p.pid] && players[p.pid].stats[type]) ? players[p.pid].stats[type].length : 0,
      ];
      for (const h of (headings[type] || [])) vals.push(fmt(p.totals[h]));
      vals.push(p.points == null ? '' : fmt(p.points));

      vals.forEach(v => {
        const td = document.createElement('td');
        td.textContent = String(v);
        tr.appendChild(td);
      });
      tbody.appendChild(tr);
    }
    table.appendChild(tbody);

    const defaultStatCol = sortable[type];
    if (Number.isFinite(defaultStatCol)) {
      sortOutputTable(table, 5 + defaultStatCol - 1, true);
    }

    wrap.appendChild(table);
    return wrap;
  }

  function sortOutputTable(table, colIndex, initialDesc) {
    const tbody = table.tBodies[0];
    if (!tbody) return;
    const rows = Array.from(tbody.rows);
    let dir = 'asc';
    if (initialDesc) {
      dir = 'desc';
    } else if (table.dataset.sortCol === String(colIndex)) {
      dir = table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
    }

    rows.sort((a, b) => {
      const av = (a.cells[colIndex]?.textContent || '').trim();
      const bv = (b.cells[colIndex]?.textContent || '').trim();
      const an = parseFloat(av);
      const bn = parseFloat(bv);
      let cmp;
      if (Number.isFinite(an) && Number.isFinite(bn) && av !== '' && bv !== '') {
        cmp = an - bn;
      } else {
        cmp = av.localeCompare(bv, undefined, { sensitivity: 'base' });
      }
      return dir === 'asc' ? cmp : -cmp;
    });

    for (const r of rows) tbody.appendChild(r);
    table.dataset.sortCol = String(colIndex);
    table.dataset.sortDir = dir;
  }

  function csvEscape(v) {
    let s = (v == null) ? '' : String(v);
    // Spreadsheet formula injection hardening (Excel/Sheets)
    if (/^[=+\-@]/.test(s)) s = `'${s}`;
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  }

  function exportCsv() {
    const agg = buildAggregates();
    const lines = [];
    lines.push(`# DLC Export Season ${season} Week ${lastCollectedWeek ?? ''}`);

    for (const type of allStatTypes) {
      if (!outputSelectedStatTypes.has(type)) continue;
      const cols = ['Name','Pos','Team','Agent','Games'].concat(headings[type] || []).concat(['Pts']);
      lines.push('');
      lines.push(`# ${type}`);
      lines.push(cols.map(csvEscape).join(','));

      for (const p of agg) {
        const totals = p.stats[type];
        if (!totals) continue;
        const row = [
          p.name,
          p.pos,
          p.tname,
          p.agent,
          (players[p.pid] && players[p.pid].stats[type]) ? players[p.pid].stats[type].length : 0,
        ];
        for (const h of (headings[type] || [])) row.push(fmt(totals[h]));
        row.push(fmt(computePoints(type, totals)));
        lines.push(row.map(csvEscape).join(','));
      }
    }

    const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `dlc_s${season}_w${lastCollectedWeek ?? 'x'}.csv`;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      URL.revokeObjectURL(a.href);
      a.remove();
    }, 0);
  }

  async function fetchDoc(url) {
    const opts = { credentials: 'same-origin' };
    if (activeCollectionAbortController) opts.signal = activeCollectionAbortController.signal;
    const res = await fetch(url, opts);
    if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
    const html = await res.text();
    return new DOMParser().parseFromString(html, 'text/html');
  }

  function detectSeasonInfoFromPage() {
    const seasonEl = document.getElementById('season');
    const text = (seasonEl?.textContent || '').trim();
    const seasonMatch = text.match(/Season\s+(\d+)/i);
    const dayMatch = text.match(/Day\s+(-?\d+)/i);
    return {
      season: seasonMatch ? parseInt(seasonMatch[1], 10) : null,
      day: dayMatch ? parseInt(dayMatch[1], 10) : null,
    };
  }

  function latestProcessedWeekFromDay(day) {
    if (!Number.isFinite(day) || day > 38) return 19;
    if (day < 1) return 1;
    return Math.max(1, Math.min(19, Math.ceil(day / 2)));
  }

  function populateWeekOptions(selectEl, seasonInfo) {
    if (!selectEl) return;
    const info = seasonInfo || detectSeasonInfoFromPage();
    const maxWeek = latestProcessedWeekFromDay(info.day);
    selectEl.innerHTML = '';
    for (let w = 1; w <= maxWeek; w++) {
      const opt = document.createElement('option');
      opt.value = String(w);
      opt.textContent = getWeekLabel(w);
      selectEl.appendChild(opt);
    }
    let defaultWeek = maxWeek;
    if (Number.isFinite(info.day) && info.day > 1 && (info.day % 2 !== 0)) {
      defaultWeek = Math.max(1, maxWeek - 1);
    }
    selectEl.value = String(defaultWeek);
  }

  function getWeekLabel(week) {
    if (week >= 1 && week <= 16) return `Regular Season - Week ${week}`;
    if (week === 17) return 'Playoffs - Quarterfinals';
    if (week === 18) return 'Playoffs - Semifinals';
    if (week === 19) return 'Playoffs - Finals';
    return String(week);
  }

  function warnIfSeasonMismatch() {
    if (seasonMismatchWarned) return;
    seasonMismatchWarned = true;

    const pageSeason = detectSeasonInfoFromPage().season;
    if (!Number.isFinite(pageSeason)) return;
    if (pageSeason === season) return;

    alert(
      `DLC season config mismatch.\n\n` +
      `Home page shows Season ${pageSeason}, but this script is set to Season ${season}.\n\n` +
      `Before running, update these values at the top of the script:\n` +
      `- season\n` +
      `- playerList`
    );
  }
})();