ylOppTacticsPreview (Modified)

Shows the most recent tactics used by an opponent

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        ylOppTacticsPreview (Modified)
// @namespace   douglaskampl
// @version     5.5.0
// @description Shows the most recent tactics used by an opponent
// @author      kostrzak16 (feat. Douglas and xente)
// @match       https://www.managerzone.com/?p=match&sub=scheduled
// @icon        https://www.google.com/s2/favicons?sz=64&domain=managerzone.com
// @grant       GM_addStyle
// @grant       GM_getResourceText
// @grant       GM_getValue
// @grant       GM_setValue
// @require     https://cdnjs.cloudflare.com/ajax/libs/spin.js/2.3.2/spin.min.js
// @resource    ylotp https://mzdv.me/mz/userscript/other/ylotp550.css
// @run-at      document-idle
// @license     MIT
// ==/UserScript==

(() => {
    'use strict';

    /**
     * @class OpponentTacticsPreview
     * @description A class responsible for all functionality related to previewing opponent tactics.
     */
    class OpponentTacticsPreview {
        /**
         * @description A set of constant values used throughout the class.
         * @static
         * @readonly
         */
        static CONSTANTS = {
            MATCH_TYPE_GROUPS: {
                'All': [
                    { id: 'no_restriction', label: 'Senior' },
                    { id: 'u23', label: 'U23' },
                    { id: 'u21', label: 'U21' },
                    { id: 'u18', label: 'U18' }
                ],
                'World League': [
                    { id: 'world_series', label: 'Senior WL' },
                    { id: 'u23_world_series', label: 'U23 WL' },
                    { id: 'u21_world_series', label: 'U21 WL' },
                    { id: 'u18_world_series', label: 'U18 WL' }
                ],
                'Official League': [
                    { id: 'series', label: 'Senior League' },
                    { id: 'u23_series', label: 'U23 League' },
                    { id: 'u21_series', label: 'U21 League' },
                    { id: 'u18_series', label: 'U18 League' }
                ]
            },
            URLS: {
                CLUBHOUSE: 'https://www.managerzone.com/?p=clubhouse',
                MATCH_LIST: 'https://www.managerzone.com/ajax.php?p=matches&sub=list&sport=soccer',
                MATCH_STATS: (matchId) => `https://www.managerzone.com/matchviewer/getMatchFiles.php?type=stats&mid=${matchId}&sport=soccer`,
                MATCH_RESULT: (matchId) => `https://www.managerzone.com/?p=match&sub=result&mid=${matchId}`,
                MATCH_CHECK: (matchId) => `https://www.managerzone.com/ajax.php?p=matchViewer&sub=check-match&type=2d&sport=soccer&mid=${matchId}`,
                PITCH_IMG: (matchId) => `https://www.managerzone.com/dynimg/pitch.php?match_id=${matchId}`,
                OFFICIAL_LEAGUE_SCHEDULE: (type, sid, tid) => `https://www.managerzone.com/ajax.php?p=league&type=${type}&sid=${sid}&tid=${tid}&sport=soccer&sub=schedule`
            },
            STORAGE_KEYS: {
                MATCH_LIMIT: 'ylopp_match_limit',
                SAVED_TEAMS: 'ylopp_saved_teams',
                USER_TEAM_ID: 'ylopp_user_team_id',
                LEAGUE_CACHE_KEY: 'ylopp_league_data'
            },
            DEFAULTS: {
                MATCH_LIMIT: 10,
                MAX_SAVED_TEAMS: 15,
                MAX_MATCH_LIMIT: 100
            },
            SELECTORS: {
                FIXTURES_LIST: '#fixtures-results-list-wrapper',
                STATS_XENTE: '#legendDiv',
                ELO_SCHEDULED: '#eloScheduledSelect',
                HOME_TEAM: '.home-team-column.flex-grow-1'
            },
            CACHE_EXPIRATION_MS: 24 * 60 * 60 * 1000
        };

        /**
         * @constructor
         * @description Initializes the class properties.
         */
        constructor() {
            this.myTeam = null;
            this.myTeamId = null;
            this.currentOpponentTid = '';
            this.spinnerInstance = null;
            this.playstyleCache = {};
            this.tooltipElement = null;
            this.tooltipHideTimeout = null;
            this.observer = new MutationObserver(() => {
                this.insertIconsAndListeners();
            });
        }

        /**
         * @description Retrieves the user's preferred match limit from storage.
         * @returns {number} The match limit.
         */
        getMatchLimit = () => {
            return GM_getValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.MATCH_LIMIT, OpponentTacticsPreview.CONSTANTS.DEFAULTS.MATCH_LIMIT);
        };

        /**
         * @description Sets the match limit in storage and provides user feedback.
         * @param {string} limit - The new limit value as a string.
         * @param {HTMLElement} confirmElem - The element to show the confirmation message.
         */
        setMatchLimit = (limit, confirmElem) => {
            const numLimit = parseInt(limit, 10);
            if (!isNaN(numLimit) && numLimit > 0 && numLimit <= OpponentTacticsPreview.CONSTANTS.DEFAULTS.MAX_MATCH_LIMIT) {
                GM_setValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.MATCH_LIMIT, numLimit);
                if (confirmElem) {
                    confirmElem.innerHTML = '<i class="fa fa-check"></i> Atualizado :)';
                    confirmElem.classList.add('visible');
                    setTimeout(() => {
                        confirmElem.classList.remove('visible');
                    }, 2000);
                }
            }
        };

        /**
         * @description Retrieves the list of recently saved teams.
         * @returns {Array<Object>} An array of team objects.
         */
        getSavedTeams = () => {
            return GM_getValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.SAVED_TEAMS, []);
        };

        /**
         * @description Saves a team ID and name to storage.
         * @param {string} teamId - The ID of the team.
         * @param {string} teamName - The name of the team.
         */
        saveTeam = (teamId, teamName) => {
            if (!teamId || !teamName || teamName.startsWith('Team ')) {
                return;
            }
            let teams = this.getSavedTeams();
            const existingIndex = teams.findIndex(team => team.id === teamId);
            if (existingIndex > -1) {
                teams.splice(existingIndex, 1);
            }
            teams.unshift({ id: teamId, name: teamName });
            const trimmedTeams = teams.slice(0, OpponentTacticsPreview.CONSTANTS.DEFAULTS.MAX_SAVED_TEAMS);
            GM_setValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.SAVED_TEAMS, trimmedTeams);
        };

        /**
         * @description Starts observing the DOM for mutations to re-insert icons.
         */
        startObserving = () => {
            const fixturesList = document.querySelector(OpponentTacticsPreview.CONSTANTS.SELECTORS.FIXTURES_LIST);
            if (fixturesList) {
                this.observer.observe(fixturesList, { childList: true, subtree: true });
            }
        };

        /**
         * @description Displays a global loading spinner.
         */
        showLoadingSpinner = () => {
            if (this.spinnerInstance) return;
            const spinnerContainer = document.createElement('div');
            spinnerContainer.id = 'spinjs-overlay';
            document.body.appendChild(spinnerContainer);
            this.spinnerInstance = new Spinner({ color: '#FFFFFF', lines: 12, top: '50%', left: '50%' }).spin(spinnerContainer);
        };

        /**
         * @description Hides the global loading spinner.
         */
        hideLoadingSpinner = () => {
            if (this.spinnerInstance) {
                this.spinnerInstance.stop();
                this.spinnerInstance = null;
            }
            const spinnerContainer = document.getElementById('spinjs-overlay');
            if (spinnerContainer) {
                spinnerContainer.remove();
            }
        };

        /**
         * @description Extracts a team name from an HTML document by finding the most frequent name.
         * @param {HTMLDocument} htmlDocument - The HTML document to parse.
         * @param {string} teamId - The ID of the team.
         * @returns {string|null} The team name or null if not found.
         */
        extractTeamNameFromHtml = (htmlDocument, teamId) => {
            const nameCounts = new Map();
            htmlDocument.querySelectorAll('.teams-wrapper a.clippable').forEach(link => {
                const linkUrl = new URL(link.href, location.href);
                const linkTid = linkUrl.searchParams.get('tid');
                if (linkTid === teamId) {
                    const fullName = link.querySelector('.full-name')?.textContent.trim();
                    if (fullName) {
                        nameCounts.set(fullName, (nameCounts.get(fullName) || 0) + 1);
                    }
                }
            });
            if (nameCounts.size > 0) {
                const mostFrequentName = [...nameCounts.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
                return mostFrequentName;
            }
            const boldTeamNameElement = htmlDocument.querySelector('.teams-wrapper a.clippable > strong > .full-name');
            const boldName = boldTeamNameElement ? boldTeamNameElement.textContent.trim() : null;
            return boldName;
        };

        /**
         * @description Fetches and processes the latest tactics for a given team.
         * @async
         * @param {string} teamId - The ID of the team.
         * @param {string} matchType - The type of match to search for.
         */
        fetchLatestTactics = async (teamId, matchType) => {
            const modal = document.getElementById('interaction-modal');
            if (modal) this.fadeOutAndRemove(modal);

            this.showLoadingSpinner();
            try {
                const response = await fetch(
                    OpponentTacticsPreview.CONSTANTS.URLS.MATCH_LIST, {
                    method: 'POST',
                    headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: `type=played&hidescore=false&tid1=${teamId}&offset=&selectType=${matchType}&limit=max`,
                    credentials: 'include'
                });

                if (!response.ok) throw new Error(`Network response was not ok: ${response.statusText}`);

                const data = await response.json();
                const parser = new DOMParser();
                const htmlDocument = parser.parseFromString(data.list, 'text/html');
                const actualTeamName = this.extractTeamNameFromHtml(htmlDocument, teamId);
                const finalTeamName = actualTeamName || `Team ${teamId}`;

                this.saveTeam(teamId, finalTeamName);
                this.currentOpponentTid = teamId;

                let matches = Array.from(htmlDocument.querySelectorAll('dl > dd.odd'))
                    .filter(this.isRelevantMatch)
                    .map(entry => this.parseMatchEntry(entry, finalTeamName))
                    .filter(Boolean);

                const officialLeagueTypes = ['series', 'u23_series', 'u21_series', 'u18_series'];
                if (officialLeagueTypes.includes(matchType)) {
                    const leagueMatches = await this.fetchLeagueScheduleMatches(htmlDocument, finalTeamName, teamId);
                    matches.push(...leagueMatches);
                }

                const uniqueMatches = Array.from(new Map(matches.map(m => [m.mid, m])).values());
                this.processTacticsData(uniqueMatches, matchType, finalTeamName);

            } catch (error) {
                this.hideLoadingSpinner();
                const message = document.createElement('div');
                message.className = 'no-tactics-message';
                message.textContent = 'Failed to fetch tactics data.';
                const container = this.createTacticsContainer('Error', 'Data Fetch');
                container.querySelector('.tactics-list').appendChild(message);
                document.body.appendChild(container);
                container.classList.add('fade-in');
            } finally {
                this.hideLoadingSpinner();
            }
        };

        /**
         * @description Fetches official league schedule matches, utilizing a single, expiring cache.
         * @async
         * @param {HTMLDocument} initialHtmlDoc - The initial HTML document to find the league link.
         * @param {string} opponentName - The name of the opponent team.
         * @param {string} teamId - The ID of the team.
         * @returns {Promise<Array<Object>>} A promise that resolves to an array of parsed match data.
         */
        fetchLeagueScheduleMatches = async (initialHtmlDoc, opponentName, teamId) => {
            try {
                const leagueLink = initialHtmlDoc.querySelector('.responsive-hide.match-reference-text-wrapper a');
                if (!leagueLink) {
                    return [];
                }

                const url = new URL(leagueLink.href, location.href);
                const sid = url.searchParams.get('sid');
                const type = url.searchParams.get('type');
                if (!sid || !type) {
                    return [];
                }

                const cacheKey = OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.LEAGUE_CACHE_KEY;
                const cachedData = GM_getValue(cacheKey, {});
                const cacheEntry = cachedData[sid];

                if (cacheEntry && (Date.now() - cacheEntry.timestamp < OpponentTacticsPreview.CONSTANTS.CACHE_EXPIRATION_MS)) {
                    const filteredMatches = cacheEntry.data.filter(m => m.homeTeamName === opponentName || m.awayTeamName === opponentName);
                    return filteredMatches.map(m => this.parseMatchData(m, opponentName));
                }

                const scheduleUrl = OpponentTacticsPreview.CONSTANTS.URLS.OFFICIAL_LEAGUE_SCHEDULE(type, sid, teamId);
                const response = await fetch(scheduleUrl);
                if (!response.ok) throw new Error(`Network response for league schedule was not ok: ${response.statusText}`);
                const text = await response.text();
                const scheduleDoc = new DOMParser().parseFromString(text, 'text/html');

                const allMatches = [];
                const rows = scheduleDoc.querySelectorAll('.hitlist.marker tr');

                rows.forEach(row => {
                    const cells = row.querySelectorAll('td');
                    if (cells.length !== 3) {
                        return;
                    }

                    const scoreLink = cells[1].querySelector('a');
                    if (!scoreLink) {
                        return;
                    }

                    const score = scoreLink?.textContent.trim();
                    if (!score || score.toLowerCase().includes('x')) {
                        return;
                    }

                    const mid = new URL(scoreLink.href, location.href).searchParams.get('mid');
                    const homeTeamName = cells[0].textContent.trim();
                    const awayTeamName = cells[2].textContent.trim();
                    allMatches.push({ mid, homeTeamName, awayTeamName, score });
                });

                cachedData[sid] = {
                    data: allMatches,
                    timestamp: Date.now()
                };
                GM_setValue(cacheKey, cachedData);

                return allMatches
                    .filter(m => m.homeTeamName === opponentName || m.awayTeamName === opponentName)
                    .map(m => this.parseMatchData(m, opponentName));

            } catch (error) {
                return [];
            }
        };

        /**
         * @description Checks if a match entry is relevant.
         * @param {HTMLElement} entry - The match entry element.
         * @returns {boolean} True if the match is relevant, otherwise false.
         */
        isRelevantMatch = (entry) => {
            const wrapper = entry.querySelector('.responsive-hide.match-reference-text-wrapper');
            return !wrapper || wrapper.querySelector('a') !== null;
        };

        /**
         * @description Parses a match entry from an HTML element.
         * @param {HTMLElement} entry - The match entry element.
         * @param {string} opponentName - The name of the opponent team.
         * @returns {Object|null} The parsed match data or null.
         */
        parseMatchEntry = (entry, opponentName) => {
            const link = entry.querySelector('a.score-shown');
            if (!link) return null;

            const dl = link.closest('dl');
            const score = link.textContent.trim();
            const homeTeamName = dl.querySelector('.home-team-column .full-name')?.textContent.trim() || 'Home';
            const awayTeamName = dl.querySelector('.away-team-column .full-name')?.textContent.trim() || 'Away';
            const mid = new URL(link.href, location.href).searchParams.get('mid');

            if (!mid) return null;
            return this.parseMatchData({ mid, homeTeamName, awayTeamName, score }, opponentName);
        };

        /**
         * @description Parses match data and determines home/away goals.
         * @param {Object} matchData - The raw match data.
         * @param {string} opponentName - The name of the opponent team.
         * @returns {Object} The parsed match data with goal information.
         */
        parseMatchData = (matchData, opponentName) => {
            let [homeGoals, awayGoals] = [0, 0];
            if (matchData.score.includes('-')) {
                const parts = matchData.score.split('-').map(x => parseInt(x.trim(), 10));
                if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
                    [homeGoals, awayGoals] = parts;
                }
            }
            const opponentIsHome = (matchData.homeTeamName === opponentName);
            return { ...matchData, homeGoals, awayGoals, opponentIsHome };
        };

        /**
         * @description Creates and displays the tactics container with match data.
         * @param {Array<Object>} matches - An array of match objects.
         * @param {string} matchType - The type of match.
         * @param {string} opponentName - The name of the opponent team.
         */
        processTacticsData = (matches, matchType, opponentName) => {
            const container = this.createTacticsContainer(matchType, opponentName);
            document.body.appendChild(container);
            const listWrapper = container.querySelector('.tactics-list');

            const limit = this.getMatchLimit();
            const limitedMatches = matches.slice(0, limit);

            if (limitedMatches.length === 0) {
                const message = document.createElement('div');
                message.className = 'no-tactics-message';
                message.textContent = 'No recent valid tactics found for this team and category.';
                listWrapper.appendChild(message);
                container.classList.add('fade-in');
                return;
            }

            limitedMatches.forEach(match => {
                const tacticUrl = OpponentTacticsPreview.CONSTANTS.URLS.PITCH_IMG(match.mid);
                const resultUrl = OpponentTacticsPreview.CONSTANTS.URLS.MATCH_RESULT(match.mid);
                const canvas = this.createCanvasWithReplacedColors(tacticUrl, match.opponentIsHome);

                const item = document.createElement('div');
                item.className = 'tactic-item';

                const opponentGoals = match.opponentIsHome ? match.homeGoals : match.awayGoals;
                const otherGoals = match.opponentIsHome ? match.awayGoals : match.homeGoals;

                if (opponentGoals > otherGoals) item.classList.add('tactic-win');
                else if (opponentGoals < otherGoals) item.classList.add('tactic-loss');
                else item.classList.add('tactic-draw');

                const statusIndicator = document.createElement('div');
                statusIndicator.className = 'playstyle-status-indicator';
                item.appendChild(statusIndicator);

                const linkA = document.createElement('a');
                linkA.href = resultUrl;
                linkA.target = '_blank';
                linkA.className = 'tactic-link';
                linkA.appendChild(canvas);

                const scoreP = document.createElement('p');
                scoreP.textContent = `${match.homeTeamName} ${match.score} ${match.awayTeamName}`;
                linkA.appendChild(scoreP);
                item.appendChild(linkA);

                this.addPlaystyleHover(match.mid, this.currentOpponentTid, item);
                listWrapper.appendChild(item);
            });
            container.classList.add('fade-in');
        };

        /**
         * @description Shows the interaction modal for selecting tactics options.
         * @param {string} teamId - The ID of the team.
         * @param {HTMLElement} sourceElement - The element that triggered the modal.
         */
        showInteractionModal = (teamId, sourceElement) => {
            const existingModal = document.getElementById('interaction-modal');
            if (existingModal) this.fadeOutAndRemove(existingModal);

            const modal = document.createElement('div');
            modal.id = 'interaction-modal';
            modal.classList.add('fade-in');

            const header = document.createElement('div');
            header.className = 'interaction-modal-header';
            const title = document.createElement('span');
            header.appendChild(title);

            const controlsWrapper = document.createElement('div');
            controlsWrapper.style.display = 'flex';
            controlsWrapper.style.alignItems = 'center';
            controlsWrapper.style.gap = '10px';

            const settingsIcon = document.createElement('span');
            settingsIcon.className = 'settings-icon';
            settingsIcon.innerHTML = '⚙';
            controlsWrapper.appendChild(settingsIcon);

            const closeIcon = document.createElement('i');
            closeIcon.className = 'fa fa-times ylotp-close-icon';
            closeIcon.onclick = () => this.fadeOutAndRemove(modal);
            controlsWrapper.appendChild(closeIcon);

            header.appendChild(controlsWrapper);
            modal.appendChild(header);

            const teamInputSection = this.createTeamInputSection(modal, teamId);
            this.createTabbedButtons(modal, teamInputSection.teamIdInput);
            const settingsPanel = this.createSettingsPanel(modal);
            settingsIcon.onclick = () => {
                settingsPanel.style.display = settingsPanel.style.display === 'block' ? 'none' : 'block';
            };
            document.body.appendChild(modal);

            const rect = sourceElement.getBoundingClientRect();
            modal.style.position = 'absolute';
            modal.style.left = `${window.scrollX + rect.left}px`;

            const initialTop = window.scrollY + rect.bottom + 5;
            modal.style.top = `${initialTop}px`;

            const modalRect = modal.getBoundingClientRect();
            if (modalRect.bottom > window.innerHeight) {
                modal.style.top = `${window.scrollY + window.innerHeight - modalRect.height - 10}px`;
            }
        };

        /**
         * @description Creates the team ID input section for the modal.
         * @param {HTMLElement} container - The modal container.
         * @param {string} initialTeamId - The initial team ID to populate the input.
         * @returns {Object} An object containing the team ID input and recents dropdown.
         */
        createTeamInputSection = (container, initialTeamId) => {
            const section = document.createElement('div');
            section.className = 'interaction-section team-input-section';

            const label = document.createElement('label');
            label.textContent = 'Team ID:';
            label.htmlFor = 'team-id-input';
            section.appendChild(label);

            const teamIdInput = document.createElement('input');
            teamIdInput.type = 'text';
            teamIdInput.id = 'team-id-input';
            teamIdInput.value = initialTeamId;
            section.appendChild(teamIdInput);

            const select = this.createRecentsDropdown(teamIdInput);
            section.appendChild(select);

            container.appendChild(section);
            return { teamIdInput, recentsSelect: select };
        };

        /**
         * @description Creates the "Recent Teams" dropdown for the modal.
         * @param {HTMLElement} teamIdInput - The team ID input element.
         * @returns {HTMLSelectElement} The created select element.
         */
        createRecentsDropdown = (teamIdInput) => {
            const select = document.createElement('select');
            select.className = 'recents-select';
            const defaultOption = document.createElement('option');
            defaultOption.textContent = 'Recent Teams';
            defaultOption.value = '';
            select.appendChild(defaultOption);

            this.getSavedTeams().forEach(team => {
                const option = document.createElement('option');
                option.value = team.id;
                option.textContent = `${team.name} (${team.id})`;
                select.appendChild(option);
            });

            select.onchange = () => {
                if (select.value) {
                    teamIdInput.value = select.value;
                }
            };
            return select;
        };

        /**
         * @description Creates the tabbed buttons for match type selection.
         * @param {HTMLElement} container - The modal container.
         * @param {HTMLInputElement} teamIdInput - The team ID input element.
         */
        createTabbedButtons = (container, teamIdInput) => {
            const tabContainer = document.createElement('div');
            tabContainer.className = 'tab-container';
            const tabHeaders = document.createElement('div');
            tabHeaders.className = 'tab-headers';
            const tabContents = document.createElement('div');
            tabContents.className = 'tab-contents';

            Object.entries(OpponentTacticsPreview.CONSTANTS.MATCH_TYPE_GROUPS).forEach(([groupName, types], index) => {
                const header = document.createElement('button');
                header.className = 'tab-header';
                header.textContent = groupName;

                const content = document.createElement('div');
                content.className = 'tab-content';
                types.forEach(type => {
                    const button = document.createElement('button');
                    button.textContent = type.label;
                    button.onclick = () => {
                        const teamId = teamIdInput.value.trim();
                        if (!teamId || isNaN(parseInt(teamId, 10))) {
                             const error = document.createElement('div');
                             error.className = 'error-message';
                             error.textContent = 'Please enter a valid Team ID.';
                             const existingError = tabContainer.querySelector('.error-message');
                             if (existingError) existingError.remove();
                             tabContainer.insertBefore(error, tabContents);
                             return;
                        }
                        this.fetchLatestTactics(teamId, type.id);
                    };
                    content.appendChild(button);
                });

                header.onclick = () => {
                    tabContainer.querySelectorAll('.tab-header').forEach(h => h.classList.remove('active'));
                    tabContainer.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none');
                    header.classList.add('active');
                    content.style.display = 'flex';
                };
                tabHeaders.appendChild(header);
                tabContents.appendChild(content);

                if (index === 0) {
                    header.classList.add('active');
                    content.style.display = 'flex';
                } else {
                    content.style.display = 'none';
                }
            });

            tabContainer.appendChild(tabHeaders);
            tabContainer.appendChild(tabContents);
            container.appendChild(tabContainer);
        };

        /**
         * @description Creates the settings panel for the modal.
         * @param {HTMLElement} modalContainer - The modal container.
         * @returns {HTMLElement} The created settings panel.
         */
        createSettingsPanel = (modalContainer) => {
            const panel = document.createElement('div');
            panel.className = 'settings-panel';
            panel.style.display = 'none';

            const limitLabel = document.createElement('label');
            limitLabel.textContent = `MatchLimit (1-${OpponentTacticsPreview.CONSTANTS.DEFAULTS.MAX_MATCH_LIMIT}):`;
            panel.appendChild(limitLabel);

            const inputWrapper = document.createElement('div');
            inputWrapper.style.display = 'flex';
            inputWrapper.style.alignItems = 'center';

            const limitInput = document.createElement('input');
            limitInput.type = 'text';
            limitInput.inputMode = 'numeric';
            limitInput.pattern = '[0-9]*';
            limitInput.value = this.getMatchLimit();

            const confirmationSpan = document.createElement('span');
            confirmationSpan.className = 'save-confirmation';

            limitInput.oninput = () => {
                limitInput.value = limitInput.value.replace(/\D/g, '');
                confirmationSpan.classList.remove('visible');
            };

            limitInput.onchange = () => this.setMatchLimit(limitInput.value, confirmationSpan);

            inputWrapper.appendChild(limitInput);
            inputWrapper.appendChild(confirmationSpan);
            panel.appendChild(inputWrapper);

            const note = document.createElement('small');
            note.textContent = 'Note: the actual number of matches found may be restricted by MZ\'s own limits.';
            panel.appendChild(note);

            modalContainer.appendChild(panel);
            return panel;
        };

        /**
         * @description Creates the container for displaying tactics.
         * @param {string} matchType - The type of match.
         * @param {string} opponent - The opponent's name.
         * @returns {HTMLElement} The created tactics container.
         */
        createTacticsContainer = (matchType, opponent) => {
            const existingContainer = document.getElementById('tactics-container');
            if (existingContainer) {
                this.fadeOutAndRemove(existingContainer);
            }
            const container = document.createElement('div');
            container.id = 'tactics-container';
            container.className = 'tactics-container';

            const header = document.createElement('div');
            header.className = 'tactics-header';
            const title = document.createElement('div');
            title.className = 'match-info-text';

            let matchTypeLabel = matchType;
            for (const group in OpponentTacticsPreview.CONSTANTS.MATCH_TYPE_GROUPS) {
                const found = OpponentTacticsPreview.CONSTANTS.MATCH_TYPE_GROUPS[group].find(t => t.id === matchType);
                if (found) {
                    matchTypeLabel = found.label;
                    break;
                }
            }

            title.innerHTML = `<div class="title-main">${opponent} (${matchTypeLabel})</div>`;
            header.appendChild(title);

            const closeButton = document.createElement('button');
            closeButton.className = 'close-button';
            closeButton.textContent = '×';
            closeButton.onclick = () => this.fadeOutAndRemove(container);
            header.appendChild(closeButton);
            container.appendChild(header);

            const listWrapper = document.createElement('div');
            listWrapper.className = 'tactics-list';
            container.appendChild(listWrapper);

            return container;
        };

        /**
         * @description Fades out and removes an element from the DOM.
         * @param {HTMLElement} el - The element to fade out.
         */
        fadeOutAndRemove = (el) => {
            if (!el) return;
            el.classList.remove('fade-in');
            el.classList.add('fade-out');
            setTimeout(() => el.remove(), 200);
        };

        /**
         * @description Identifies the user's team name from the current page.
         * @returns {string|null} The user's team name or null.
         */
        identifyUserTeamName = () => {
            const ddRows = document.querySelectorAll('#fixtures-results-list > dd.odd');
            if (ddRows.length === 0) return null;
            const countMap = new Map();
            ddRows.forEach(dd => {
                const homeName = dd.querySelector('.home-team-column .full-name')?.textContent.trim();
                const awayName = dd.querySelector('.away-team-column .full-name')?.textContent.trim();
                if (homeName) countMap.set(homeName, (countMap.get(homeName) || 0) + 1);
                if (awayName) countMap.set(awayName, (countMap.get(awayName) || 0) + 1);
            });
            if (countMap.size === 0) return null;
            const mostFrequentName = [...countMap.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
            return mostFrequentName;
        };

        /**
         * @description Inserts the magnifying glass icons and sets up event listeners.
         */
        insertIconsAndListeners = () => {
            if (!this.myTeam) this.myTeam = this.identifyUserTeamName();
            if (!this.myTeam) {
                return;
            }

            document.querySelectorAll('dd.odd').forEach(dd => {
                const selectWrapper = dd.querySelector('.set-default-wrapper');
                if (selectWrapper && !selectWrapper.querySelector('.magnifier-icon')) {
                    const homeTeamName = dd.querySelector('.home-team-column .full-name')?.textContent.trim();
                    const awayTeamName = dd.querySelector('.away-team-column .full-name')?.textContent.trim();
                    const homeTeamLink = dd.querySelector('.home-team-column a.clippable');
                    const awayTeamLink = dd.querySelector('.away-team-column a.clippable');

                    let opponentName = null;
                    let opponentTid = null;

                    if (homeTeamName === this.myTeam && awayTeamName && awayTeamLink) {
                        opponentName = awayTeamName;
                        if (awayTeamLink.href) opponentTid = new URL(awayTeamLink.href, location.href).searchParams.get('tid');
                    } else if (awayTeamName === this.myTeam && homeTeamName && homeTeamLink) {
                        opponentName = homeTeamName;
                        if (homeTeamLink.href) opponentTid = new URL(homeTeamLink.href, location.href).searchParams.get('tid');
                    }

                    if (opponentName && opponentTid && (opponentTid !== this.myTeamId)) {
                        const iconWrapper = document.createElement('span');
                        iconWrapper.className = 'magnifier-icon';
                        iconWrapper.dataset.tid = opponentTid;
                        iconWrapper.dataset.opponent = opponentName;
                        iconWrapper.title = 'Check opponent latest tactics';
                        iconWrapper.textContent = '🔍';
                        selectWrapper.querySelector('select')?.insertAdjacentElement('afterend', iconWrapper);
                    }
                }
            });
        };

        /**
         * @description Creates a canvas with a pitch image and replaces colors.
         * @param {string} imageUrl - The URL of the image.
         * @param {boolean} opponentIsHome - True if the opponent is the home team.
         * @returns {HTMLCanvasElement} The created canvas element.
         */
        createCanvasWithReplacedColors = (imageUrl, opponentIsHome) => {
            const canvas = document.createElement('canvas');
            canvas.width = 150;
            canvas.height = 200;
            const context = canvas.getContext('2d');
            const image = new Image();
            image.crossOrigin = 'Anonymous';
            image.onload = () => {
                if (opponentIsHome) {
                    context.translate(canvas.width / 2, canvas.height / 2);
                    context.rotate(Math.PI);
                    context.translate(-canvas.width / 2, -canvas.height / 2);
                }
                context.drawImage(image, 0, 0, canvas.width, canvas.height);
                const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
                const data = imageData.data;
                const darkGreen = { r: 0, g: 100, b: 0 };
                for (let i = 0; i < data.length; i += 4) {
                    const r = data[i], g = data[i + 1], b = data[i + 2];
                    const isBlack = r < 30 && g < 30 && b < 30;
                    const isYellow = r > 200 && g > 200 && b < 100;
                    if (opponentIsHome) {
                        if (isYellow) { data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; }
                        else if (isBlack) { data[i] = darkGreen.r; data[i + 1] = darkGreen.g; data[i + 2] = darkGreen.b; }
                    } else {
                        if (isBlack) { data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; }
                        else if (isYellow) { data[i] = darkGreen.r; data[i + 1] = darkGreen.g; data[i + 2] = darkGreen.b; }
                    }
                }
                const tempData = new Uint8ClampedArray(data);
                for (let y = 1; y < canvas.height - 1; y++) {
                    for (let x = 1; x < canvas.width - 1; x++) {
                        const i = (y * canvas.width + x) * 4;
                        if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0) {
                            for (let dy = -1; dy <= 1; dy++) {
                                for (let dx = -1; dx <= 1; dx++) {
                                    if (dx === 0 && dy === 0) continue;
                                    const ni = ((y + dy) * canvas.width + (x + dx)) * 4;
                                    if (!(data[ni] === 0 && data[ni + 1] === 0 && data[ni + 2] === 0)) {
                                        tempData[i] = 255; tempData[i + 1] = 255; tempData[i + 2] = 255;
                                    }
                                }
                            }
                        }
                    }
                }
                context.putImageData(new ImageData(tempData, canvas.width, canvas.height), 0, 0);
            };
            image.src = imageUrl;
            return canvas;
        };

        /**
         * @description Ensures a match file is ready for fetching stats.
         * @async
         * @param {string} matchId - The ID of the match.
         * @param {number} [attempt=1] - The current attempt number.
         * @returns {Promise<void>} A promise that resolves when the file is ready.
         */
        ensureMatchFileIsReady = (matchId, attempt = 1) => {
            const maxAttempts = 5;
            return new Promise(async (resolve, reject) => {
                if (attempt > maxAttempts) {
                    const error = new Error(`File preparation failed after ${maxAttempts} attempts.`);
                    return reject(error);
                }
                try {
                    const response = await fetch(OpponentTacticsPreview.CONSTANTS.URLS.MATCH_CHECK(matchId));
                    if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
                    const data = await response.json();

                    switch (data.response) {
                        case 'ok':
                            resolve();
                            break;
                        case 'queued':
                            setTimeout(() => this.ensureMatchFileIsReady(matchId, attempt + 1).then(resolve).catch(reject), 3000);
                            break;
                        default:
                            reject(new Error(`Match file unavailable: ${data.response}.`));
                            break;
                    }
                } catch (error) {
                    reject(error);
                }
            });
        };

        _parseTimeToSeconds = (timeString) => {
            if (!timeString || !timeString.includes(':')) return 0;
            const parts = timeString.split(':');
            const minutes = parseInt(parts[0], 10);
            const seconds = parseFloat(parts[1]);
            return isNaN(minutes) || isNaN(seconds) ? 0 : (minutes * 60) + seconds;
        };

        _getSortedGoalEvents = (xml) => {
            const events = [];
            xml.querySelectorAll('Player > Goal').forEach(node => {
                const timeAttr = node.getAttribute('clock') || node.getAttribute('time');
                if (!timeAttr) return;
                const timeInSeconds = this._parseTimeToSeconds(timeAttr);
                const teamId = node.getAttribute('team');
                if (teamId) events.push({ time: timeInSeconds, type: 'Goal', teamId });
            });
            return events.sort((a, b) => a.time - b.time);
        };

        _calculateScoreAtTime = (sortedGoalEvents, targetTime, homeTeamId, awayTeamId) => {
            let homeScore = 0;
            let awayScore = 0;
            for (const event of sortedGoalEvents) {
                if (event.time >= targetTime) break;
                if (event.teamId === homeTeamId) homeScore++;
                if (event.teamId === awayTeamId) awayScore++;
            }
            return { homeScore, awayScore };
        };

        /**
         * @description Fetches and formats playstyle changes from match stats.
         * @async
         * @param {string} mid - The ID of the match.
         * @param {string} opponentTid - The ID of the opponent team.
         * @returns {Promise<string>} A promise that resolves to an HTML string with playstyle data.
         */
        fetchPlaystyleChanges = async (mid, opponentTid) => {
            await this.ensureMatchFileIsReady(mid);

            const response = await fetch(OpponentTacticsPreview.CONSTANTS.URLS.MATCH_STATS(mid));
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            const text = await response.text();
            const xml = new DOMParser().parseFromString(text, 'text/xml');
            const opponentTeamNode = xml.querySelector(`Team[id="${opponentTid}"]`);
            if (!opponentTeamNode) return 'Opponent data not found.';

            const isVisiting = opponentTeamNode.getAttribute('visiting') === '1';
            const homeTeamId = xml.querySelector('Team[visiting="0"]')?.getAttribute('id');
            const awayTeamId = xml.querySelector('Team[visiting="1"]')?.getAttribute('id');
            const tooltipLines = [];

            tooltipLines.push(`Tactic: ${opponentTeamNode.getAttribute('tactic') || 'N/A'}`);
            tooltipLines.push(`Playstyle: ${opponentTeamNode.getAttribute('playstyle') || 'N/A'}`);
            tooltipLines.push(`Aggression: ${opponentTeamNode.getAttribute('aggression') || 'N/A'}`);

            const changeNodes = [...xml.querySelectorAll('Events Tactic')].filter(node => node.getAttribute('teamId') === opponentTid);
            if (changeNodes.length > 0) {
                const sortedGoalEvents = this._getSortedGoalEvents(xml);
                tooltipLines.push('<br><strong>Changes</strong>');
                changeNodes.forEach(node => {
                    const changeType = node.getAttribute('type');
                    if (!['playstyle', 'aggression', 'tactic'].includes(changeType)) return;
                    const clock = node.getAttribute('clock') || node.getAttribute('time');
                    const timeInSeconds = this._parseTimeToSeconds(clock);
                    const minute = Math.floor(timeInSeconds / 60);
                    const newSetting = node.getAttribute('new_setting');
                    const { homeScore, awayScore } = this._calculateScoreAtTime(sortedGoalEvents, timeInSeconds, homeTeamId, awayTeamId);
                    const scoreString = isVisiting ? `${awayScore}-${homeScore}` : `${homeScore}-${awayScore}`;
                    tooltipLines.push(`Min ${minute}: ${changeType} → ${newSetting} (Score: ${scoreString})`);
                });
            }

            const result = tooltipLines.length > 0 ? tooltipLines.join('<br>') : 'No relevant tactical data found.';
            return result;
        };

        /**
         * @description Adds hover functionality to tactic items to show playstyle data.
         * @param {string} mid - The ID of the match.
         * @param {string} opponentTid - The ID of the opponent team.
         * @param {HTMLElement} tacticItemElement - The element representing the tactic.
         */
        addPlaystyleHover = (mid, opponentTid, tacticItemElement) => {
            const indicatorElement = tacticItemElement.querySelector('.playstyle-status-indicator');
            const loadAndShowTooltip = async () => {
                const cacheKey = `${mid}-${opponentTid}`;
                const cacheEntry = this.playstyleCache[cacheKey];

                if (cacheEntry?.status === 'success' || cacheEntry?.status === 'error') {
                    this.tooltipElement.innerHTML = cacheEntry.content;
                } else {
                    this.tooltipElement.innerHTML = 'Loading...';
                }

                if (this.tooltipElement.style.display !== 'block') {
                    this.tooltipElement.style.display = 'block';
                }

                if (!cacheEntry || cacheEntry.status === 'error') {
                    this.playstyleCache[cacheKey] = { status: 'loading' };
                    if (indicatorElement) {
                        indicatorElement.innerHTML = '';
                        new Spinner({ lines: 8, length: 3, width: 2, radius: 4, scale: 0.5, color: '#FFFFFF', position: 'relative' }).spin(indicatorElement);
                    }
                    try {
                        const content = await this.fetchPlaystyleChanges(mid, opponentTid);
                        this.playstyleCache[cacheKey] = { status: 'success', content };
                        if (this.tooltipElement.style.display === 'block') this.tooltipElement.innerHTML = content;
                        if (indicatorElement) indicatorElement.innerHTML = '✅';
                    } catch (error) {
                        const errorMessage = 'Match data is not available. Probably a WO :)';
                        this.playstyleCache[cacheKey] = { status: 'error', content: errorMessage };
                        if (this.tooltipElement.style.display === 'block') this.tooltipElement.innerHTML = errorMessage;
                        if (indicatorElement) indicatorElement.innerHTML = '❌';
                    }
                }
            };

            tacticItemElement.addEventListener('mouseenter', () => {
                clearTimeout(this.tooltipHideTimeout);
                loadAndShowTooltip();
            });

            tacticItemElement.addEventListener('mousemove', (ev) => {
                this.tooltipElement.style.top = `${ev.pageY + 15}px`;
                this.tooltipElement.style.left = `${ev.pageX + 5}px`;
            });

            tacticItemElement.addEventListener('mouseleave', () => {
                this.tooltipHideTimeout = setTimeout(() => {
                    this.tooltipElement.style.display = 'none';
                }, 200);
            });
        };

        /**
         * @description Creates a global tooltip element.
         */
        createGlobalTooltip = () => {
            this.tooltipElement = document.createElement('div');
            this.tooltipElement.className = 'playstyle-tooltip';
            document.body.appendChild(this.tooltipElement);
            this.tooltipElement.addEventListener('mouseenter', () => clearTimeout(this.tooltipHideTimeout));
            this.tooltipElement.addEventListener('mouseleave', () => {
                this.tooltipHideTimeout = setTimeout(() => { this.tooltipElement.style.display = 'none'; }, 200);
            });
        };

        /**
         * @description Waits for ELO values from another script before inserting icons.
         */
        waitForEloValues = () => {
            const interval = setInterval(() => {
                const elements = document.querySelectorAll(OpponentTacticsPreview.CONSTANTS.SELECTORS.HOME_TEAM);
                if (elements.length > 0 && elements[elements.length - 1]?.innerHTML.includes('br')) {
                    clearInterval(interval);
                    this.insertIconsAndListeners();
                }
            }, 100);
            setTimeout(() => clearInterval(interval), 1500);
        };

        /**
         * @description Handles global click events for the script's UI.
         * @param {Event} e - The click event object.
         */
        handleClickEvents = (e) => {
            const clickedMagnifier = e.target.closest('.magnifier-icon');
            if (clickedMagnifier) {
                e.preventDefault();
                e.stopPropagation();
                const tid = clickedMagnifier.dataset.tid;
                const name = clickedMagnifier.dataset.opponent;
                if (!tid) return;
                this.saveTeam(tid, name);
                this.showInteractionModal(tid, clickedMagnifier);
                return;
            }
            const interactionModal = document.getElementById('interaction-modal');
            if (interactionModal && !interactionModal.contains(e.target)) {
                this.fadeOutAndRemove(interactionModal);
            }
            const tacticsContainer = document.getElementById('tactics-container');
            if (tacticsContainer && !e.target.closest('#tactics-container')) {
                this.fadeOutAndRemove(tacticsContainer);
            }
        };

        /**
         * @description Initializes the user's team ID.
         * @async
         */
        initUserTeamId = async () => {
            let storedId = GM_getValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.USER_TEAM_ID);
            if (storedId) {
                this.myTeamId = storedId;
                return;
            }
            try {
                const response = await fetch(OpponentTacticsPreview.CONSTANTS.URLS.CLUBHOUSE);
                const text = await response.text();
                const match = text.match(/dynimg\/badge\.php\?team_id=(\d+)/);
                if (match && match[1]) {
                    this.myTeamId = match[1];
                    GM_setValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.USER_TEAM_ID, this.myTeamId);
                }
            } catch (error) {}
        };

        /**
         * @description Main initialization function for the userscript.
         * @async
         */
        init = async () => {
            GM_addStyle(GM_getResourceText('ylotp'));
            this.createGlobalTooltip();
            await this.initUserTeamId();
            const statsXenteRunning = document.querySelector(OpponentTacticsPreview.CONSTANTS.SELECTORS.STATS_XENTE);
            const eloScheduledSelected = document.querySelector(OpponentTacticsPreview.CONSTANTS.SELECTORS.ELO_SCHEDULED)?.checked;

            if (statsXenteRunning && eloScheduledSelected) {
                this.waitForEloValues();
            } else {
                this.insertIconsAndListeners();
            }
            this.startObserving();
            document.body.addEventListener('click', this.handleClickEvents, true);
        };
    }

    const otp = new OpponentTacticsPreview();
    otp.init();
})();