/*
** Copyright (C) 2021-2026 initMAX s.r.o.
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
** without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
** See the GNU Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/

class CWidgetTetrisMax extends CWidget {

    /**
     * Milliseconds between two automatic drops at level 1, when the server
     * sent none. Mirrors Speed::NORMAL - a fallback, not a second source of
     * truth; includes/Speed.php is where the three paces are decided.
     */
    static DEFAULT_FALL_SPEED = 500;

    #tetrismax_element = null;
    #tetrismax_board_element = null;
    #tetrismax_score_element = null;
    #tetrismax_level_element = null;
    #tetrismax_lines_element = null;
    #tetrismax_next_element = null;

    #bar_element = null;

    #game_parameters = null;
    #game_loaded = false;
    #score = null;

    /* Configure */
    #fall_speed = CWidgetTetrisMax.DEFAULT_FALL_SPEED;
    #fall_speed_increase_per_level = 1.2;
    #original_fall_speed = this.#fall_speed;

    /* Objects */
    #game_board = null;
    #current_tetromino = null;
    #next_tetrominos = [];

    /** Helpers - don't touch */
    #fall_interval = null;
    #start_time = null;
    #score_interval = null;
    #current_level = null;
    #deleted_lines = null;
    #is_clearing_lines = false;

    /*
     * ONE CLIENT, 6.0 - 7.4.
     *
     * 7.0 renamed the whole widget shell contract. 6.0, 6.2 AND 6.4 all use
     * the older names - 6.4 is easy to miss, because it runs this very
     * manifest-v2 tree against the older shell - so every lifecycle hook below
     * is bridged rather than duplicated into a second client. A forked legacy
     * client is how a fix lands on one line and not the other.
     *
     *   7.0+            6.0 / 6.2 / 6.4
     *   onActivate()    _doActivate()
     *   promiseUpdate() _promiseUpdate()
     *   setContents()   _setContents({name, body, messages, info, debug})
     *   _body           _content_body
     *   _contents       (no separate element - _content_body IS the contents)
     */

    /** 7.0 calls onActivate(); the older shells call _doActivate(). */
    _doActivate() {
        super._doActivate();
        this.onActivate();
    }

    /** The element the widget draws into: _body on 7.0, _content_body before. */
    get _body() {
        return super._body !== undefined ? super._body : this._content_body;
    }

    set _body(value) {
        this._content_body = value;
    }

    /**
     * The padded box around the body.
     *
     * 7.0 wraps the body in its own `_contents` element; the older shells have
     * no such wrapper and put the padding on `_content_body` itself. Without
     * this bridge onActivate() dereferenced undefined on 6.0 - 6.4 and the
     * whole widget threw during activation.
     *
     * A plain property, not a private field: the base constructor assigns
     * `this._contents` while running super(), which is BEFORE a subclass
     * private field exists - writing one there is a TypeError.
     */
    get _contents() {
        return this._contents_element !== undefined ? this._contents_element : this._content_body;
    }

    set _contents(value) {
        this._contents_element = value;
    }

    onActivate() {
        this._contents.style.backgroundColor = 'transparent';
        this._contents.style.border = 'none';
    }

    promiseUpdate() {
        // ONE render, then the game runs in the browser: a periodic refresh
        // would replace the board mid-fall and reset the score.
        if (!this.#game_loaded) {
            this.#game_loaded = true;

            this.#superPromiseUpdate();
        }

        return Promise.resolve();
    }

    /** The older shells call this name; route it to the one implementation. */
    _promiseUpdate() {
        return this.promiseUpdate();
    }

    #superPromiseUpdate() {
        return typeof super.promiseUpdate === 'function'
            ? super.promiseUpdate()
            : super._promiseUpdate();
    }

    /**
     * The older shells hand the response to _processUpdateResponse and never
     * call setContents at all, so the board was built on 7.x and nowhere else.
     */
    _processUpdateResponse(response) {
        this.setContents(response);
    }

    #superSetContents(response) {
        if (typeof super.setContents === 'function') {
            super.setContents(response);

            return;
        }

        // 6.0 - 6.4: the base takes the pieces apart itself, and it is also
        // what writes the widget header and the debug/messages blocks.
        super._setContents({
            name: response.name,
            body: response.body,
            messages: response.messages,
            info: response.info,
            debug: response.debug
        });
    }

    setContents(response) {
        this.#superSetContents(response);

        this.#tetrismax_element = this._body.querySelector('.tetrismax');
        this.#tetrismax_board_element = this.#tetrismax_element.querySelector('.tetrismax__board');
        this.#tetrismax_score_element = this.#tetrismax_element.querySelector('.tetrismax__score .score-item__value');
        this.#tetrismax_level_element = this.#tetrismax_element.querySelector('.tetrismax__level .score-item__value');
        this.#tetrismax_lines_element = this.#tetrismax_element.querySelector('.tetrismax__lines .score-item__value');
        this.#tetrismax_next_element = this.#tetrismax_element.querySelector('.tetrismax__next .score-item__grid-container');
        this.#bar_element = this.#tetrismax_element.querySelector('.bar');

        this.readGameParameters();
        this.registerGameControls();
        this.gameStart();
    }

    renderSvgText(element, text) {
        element.innerHTML = "";

        [...text].forEach(char => {
            const span = document.createElement("div");
            span.classList.add("char");
            span.dataset.id = char;
            element.appendChild(span);
        });
    }

    readGameParameters() {
        this.#game_parameters = JSON.parse(this.#tetrismax_element.dataset.gameParameters);

        // How fast the pieces fall at level 1 - the widget's one setting. The
        // server resolves Slow/Normal/Fast to milliseconds so the two trees
        // cannot disagree about what a stored value means.
        this.#original_fall_speed = Number(this.#game_parameters.fall_speed)
            || CWidgetTetrisMax.DEFAULT_FALL_SPEED;
        this.#fall_speed = this.#original_fall_speed;
    }

    initializeGameBoard() {
        // GameBoard APPENDS its cells, so the element has to be emptied here -
        // a restart would otherwise stack a second grid on top of the first.
        // The clear belongs with the build, not with the score reset it used
        // to sit in, because the two ran in the wrong order.
        this.#tetrismax_board_element.innerHTML = '';

        this.#game_board = new GameBoard(
            this.#tetrismax_board_element,
            this.#game_parameters.board.size_x,
            this.#game_parameters.board.size_y
        );
    }


    gameStart() {
        // THE BOARD FIRST. resetProperties() redraws the severity bar, which
        // asks the board how high the stack is - so a board that does not
        // exist yet is a TypeError before the first piece is ever dealt. It
        // used to be hidden: setContents() built a throwaway board on the way
        // past, and removing that line is what made the ordering visible.
        this.initializeGameBoard();

        this.resetProperties();
        this.startScoreTimer();

        this.spawnTetromino(); // Spawn the first tetromino, the game begins.
        this.startFallingTetromino(); // Automatic falling of the tetromino
    }

    resetProperties() {
        this.#score = null;

        this.renderSvgText(this.#tetrismax_score_element, '00:00');
        this.renderSvgText(this.#tetrismax_level_element, '1');
        this.renderSvgText(this.#tetrismax_lines_element, '0');

        this.#start_time = Date.now();

        this.#fall_interval = null;
        this.#score_interval = null;
        this.#current_level = 1;
        this.#deleted_lines = 0;
        this.#fall_speed = this.#original_fall_speed;
        
        this.updateSeverityBar();
    }

    gameOver() {
        this.#score = Number(this.#deleted_lines) * Number(this.#current_level)
            * Math.floor((Date.now() - this.#start_time) / 1000);

        clearInterval(this.#fall_interval);
        clearInterval(this.#score_interval);

        this.#current_tetromino = null;

        // The score is shown and then forgotten. There is no board to post it
        // to and nothing is written to Zabbix or to this browser - the game is
        // local, which is the whole point of the widget.
        const dialog = document.createElement('div');

        dialog.classList.add('tetrismax__dialog');
        dialog.innerHTML = `
            <div class="tetrismax__dialog-content hide">
                <div class="dialog-item">
                    <span class="score-item__label" data-id="score"></span>
                    <div id="score" class="score-item__value"></div>
                </div>
                <button class="replay-button" id="tetrismax-restart">
                    <div class="image"></div>
                </button>
            </div>
        `;
        this.#tetrismax_element.appendChild(dialog);

        this.renderSvgText(dialog.querySelector('#score'), this.#score.toString());

        dialog.querySelector('#tetrismax-restart').addEventListener('click', () => {
            dialog.remove();
            this.gameStart();
        });

        const dialogContent = dialog.querySelector('.hide');

        void dialogContent.offsetWidth;
        dialogContent.classList.add('show');
    }

    registerGameControls() {
        document.addEventListener('keydown', (event) => {
            if (!this.#current_tetromino || this.#is_clearing_lines) return; 

            switch (event.key) {
                case 'ArrowLeft': this.#current_tetromino.move(-1, 0); break;
                case 'ArrowRight': this.#current_tetromino.move(1, 0); break;
                case 'ArrowDown': 
                    if (!this.#current_tetromino.move(0, 1)) this.spawnTetromino(); // Can't move down, spawn new tetromino
                    break;
                case 'ArrowUp': this.#current_tetromino.rotate(); break;
                case ' ': this.#current_tetromino.hardDrop(); this.spawnTetromino(); break; // Automatically spawn new tetromino
                default: return;
            }

            event.preventDefault();
        });
    }

    async spawnTetromino() {
        if (this.#current_tetromino) {
            this.#current_tetromino.cellsOnBoard.forEach(([x, y]) => {
                this.#game_board.updateCell(x, y, this.#current_tetromino.color, 'dropped');
            });
        }

        if (this.#fall_interval) clearInterval(this.#fall_interval);

        this.#is_clearing_lines = true; 
        let linesDeleted = await this.#game_board.clearFullLines();
        this.#is_clearing_lines = false; 

        if (linesDeleted > 0) {
            this.increaseLine(linesDeleted);
        }

        this.startFallingTetromino();

        this.updateSeverityBar();

        const tetrominoes = Object.values(this.#game_parameters.tetrominoes);
        const totalChance = tetrominoes.reduce((sum, t) => sum + t.spawnChance, 0);

        // fill queue if empty
        while (this.#next_tetrominos.length < 3) {
            this.#next_tetrominos.push(this.getRandomTetromino(tetrominoes, totalChance));
        }

        const selected = this.#next_tetrominos.shift(); // take first to spawn

        // push new random tetromino to maintain queue size
        this.#next_tetrominos.push(this.getRandomTetromino(tetrominoes, totalChance));

        // render all upcoming tetrominos in next element
        this.renderNextTetrominoQueue();

        const spawnX = Math.floor(this.#game_parameters.board.size_x / 2) - 2;
        const spawnY = 0;

        const newTetromino = new Tetromino(
            selected.type,
            selected.rotations,
            selected.getColor,
            this.#game_board,
            spawnX,
            spawnY
        );

        if (!newTetromino.canPlace()) {
            this.gameOver();
            return;
        }

        this.#current_tetromino = newTetromino;
        this.#current_tetromino.render();
    }

    getRandomTetromino(tetrominoes, totalChance) {
       let rand = Math.random() * totalChance;
        for (const t of tetrominoes) {
            if (rand < t.spawnChance) {
                return t;
            }
            rand -= t.spawnChance;
        }
        return tetrominoes[0]; // fallback
    }

    renderNextTetromino(tetrominoData) {
        this.#tetrismax_next_element.innerHTML = '';

        const shape = tetrominoData.rotations[0];

        const minX = Math.min(...shape.map(([x, _]) => x));
        const maxX = Math.max(...shape.map(([x, _]) => x));
        const minY = Math.min(...shape.map(([_, y]) => y));
        const maxY = Math.max(...shape.map(([_, y]) => y));

        const gridWidth = maxX - minX + 1;
        const gridHeight = maxY - minY + 1;

        const grid = document.createElement('div');
        grid.classList.add('tetrismax__next__grid');
        grid.style.gridTemplateColumns = `repeat(${gridWidth}, 1fr)`;
        grid.style.gridTemplateRows = `repeat(${gridHeight}, 1fr)`;

        for (let i = 0; i < gridWidth * gridHeight; i++) {
            const cell = document.createElement('div');
            cell.classList.add('tetrismax__next__grid__cell')
            grid.appendChild(cell);
        }

        shape.forEach(([x, y]) => {
            const gridX = x - minX;
            const gridY = y - minY;
            const index = gridY * gridWidth + gridX;
            const cell = grid.children[index];
            if (cell) {
                cell.dataset.value = tetrominoData.getColor;
            }
        });

        this.#tetrismax_next_element.appendChild(grid);
    }

    renderNextTetrominoQueue() {
        this.#tetrismax_next_element.innerHTML = '';

        this.#next_tetrominos.forEach(tetrominoData => {
            const grid = document.createElement('div');
            grid.classList.add('tetrismax__next__grid');

            const shape = tetrominoData.rotations[0];

            const minX = Math.min(...shape.map(([x, _]) => x));
            const maxX = Math.max(...shape.map(([x, _]) => x));
            const minY = Math.min(...shape.map(([_, y]) => y));
            const maxY = Math.max(...shape.map(([_, y]) => y));

            const gridWidth = maxX - minX + 1;
            const gridHeight = maxY - minY + 1;

            grid.style.gridTemplateColumns = `repeat(${gridWidth}, 1fr)`;
            grid.style.gridTemplateRows = `repeat(${gridHeight}, 1fr)`;

            for (let i = 0; i < gridWidth * gridHeight; i++) {
                const cell = document.createElement('div');
                cell.classList.add('tetrismax__next__grid__cell');
                grid.appendChild(cell);
            }

            shape.forEach(([x, y]) => {
                const gridX = x - minX;
                const gridY = y - minY;
                const index = gridY * gridWidth + gridX;
                const cell = grid.children[index];
                if (cell) {
                    cell.dataset.value = tetrominoData.getColor;
                }
            });

            this.#tetrismax_next_element.appendChild(grid);
        });
    }

    startFallingTetromino() {
        if (this.#fall_interval) clearInterval(this.#fall_interval);

        this.#fall_interval = setInterval(() => {
            if (!this.#current_tetromino) return;
            if (!this.#current_tetromino.move(0, 1)) this.spawnTetromino();
        }, this.#fall_speed);
    }

    startScoreTimer() {
        if (this.#score_interval) {
            clearInterval(this.#score_interval);
        }

        this.#score_interval = setInterval(() => {
            const elapsed = Math.floor((Date.now() - this.#start_time) / 1000);
            this.renderSvgText(this.#tetrismax_score_element, this.formatTime(elapsed));

            if (elapsed > 0 && elapsed % 60 === 0) { // Every minute trigger
                this.increaseLevel(1);
            }
        }, 1000);
    }

    increaseLevel(increaseBy) {
        this.#current_level += increaseBy;
        this.renderSvgText(this.#tetrismax_level_element, this.#current_level.toString());

        this.#fall_speed = Math.floor(
            this.#fall_speed / (this.#fall_speed_increase_per_level * increaseBy)
        );

        this.startFallingTetromino();
    }

    increaseLine(deletedLines) {
        this.#deleted_lines += deletedLines;
        this.renderSvgText(this.#tetrismax_lines_element, this.#deleted_lines.toString());
    }

    formatTime(seconds) {
        const hrs = Math.floor(seconds / 3600);
        const mins = Math.floor((seconds % 3600) / 60);
        const secs = seconds % 60;

        if (hrs > 0) {
            return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
        }
        return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
    }


    getSeverityLevel(row) {
        const boardHeight = this.#game_parameters.board.size_y;

        if (row === -1) return 5;

        const segmentHeight = boardHeight / 6;

        const severityLevel = Math.floor(row / segmentHeight);

        return Math.min(5, Math.max(0, severityLevel));
    }

    updateSeverityBar() {
        const highestRow = this.#game_board.getHighestOccupiedRow();
        const severityLevel = this.getSeverityLevel(highestRow);

        const barItems = this.#bar_element.querySelectorAll('.bar__item .image');

        barItems.forEach((image, index) => {
            if (index === severityLevel) {
                image.classList.remove('hidden');
            } else {
                image.classList.add('hidden');
            }
        });
    }
    
}
