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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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`
    );
  }
})();