<?php

/*
 * This file is part of Psy Shell.
 *
 * (c) 2012-2026 Justin Hileman
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Psy\Readline\Interactive\Actions;

use Psy\Completion\CompletionEngine;
use Psy\Completion\CompletionRequest;
use Psy\Completion\FuzzyMatcher;
use Psy\Readline\Interactive\Helper\CompletionRenderer;
use Psy\Readline\Interactive\Helper\CurrentWord;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;

/**
 * Tab completion action.
 *
 * Triggers context-aware tab completion when the Tab key is pressed.
 */
class TabAction implements ActionInterface
{
    private ?CompletionEngine $completer = null;
    private bool $smartBrackets = false;

    /** @var string[] */
    private array $currentMatches = [];
    /** @var string[] */
    private array $filteredMatches = [];
    private int $selectedIndex = 0;
    private string $filterText = '';
    private bool $inInteractiveMode = false;
    private ?bool $interactiveSelectionEnabled = null;

    /** @var int Viewport: first visible row when scrolling. */
    private int $scrollOffset = 0;
    /** @var bool Viewport: whether the menu has expanded to full height. */
    private bool $expanded = false;
    private int $totalRows = 0;
    private int $totalColumns = 0;

    public function __construct(?CompletionEngine $completer = null, bool $smartBrackets = false)
    {
        $this->completer = $completer;
        $this->smartBrackets = $smartBrackets;
    }

    /**
     * Set the CompletionEngine instance.
     */
    public function setCompleter(CompletionEngine $completer): void
    {
        $this->completer = $completer;
    }

    /**
     * Force-enable or disable interactive menu mode (for tests).
     *
     * @internal
     */
    public function setInteractiveSelectionEnabled(?bool $enabled): void
    {
        $this->interactiveSelectionEnabled = $enabled;
    }

    /**
     * {@inheritdoc}
     */
    public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
    {
        if ($this->completer === null) {
            $terminal->bell();

            return true;
        }

        $text = $buffer->getText();
        $cursor = $buffer->getCursor();

        $matches = $this->completer->getCompletions(
            new CompletionRequest($text, $cursor, CompletionRequest::MODE_TAB)
        );

        if (empty($matches)) {
            return true;
        }

        if (\count($matches) === 1) {
            $this->insertMatch($buffer, $matches[0]);

            return true;
        }

        $commonPrefix = $this->getCommonPrefix($matches);
        $currentWord = CurrentWord::extract($text, $cursor);

        if (!empty($commonPrefix) && $commonPrefix !== $currentWord) {
            $this->insertMatch($buffer, $commonPrefix);
            $currentWord = $commonPrefix;
        }

        $this->currentMatches = \array_values($matches);
        $this->filterText = $currentWord;
        $this->filteredMatches = $this->currentMatches;
        $this->selectedIndex = 0;
        $this->scrollOffset = 0;
        $this->expanded = false;
        $this->inInteractiveMode = true;

        $readline->enterMenuMode();
        try {
            $this->updateOverlay($buffer, $terminal, $readline);

            return $this->handleInteractiveSelection($buffer, $terminal, $readline);
        } finally {
            $readline->exitMenuMode();
        }
    }

    /**
     * {@inheritdoc}
     */
    public function getName(): string
    {
        return 'tab-completion';
    }

    /**
     * Insert a match at the cursor position.
     */
    private function insertMatch(Buffer $buffer, string $match): void
    {
        $text = $buffer->getText();
        $cursor = $buffer->getCursor();
        $textLen = \mb_strlen($text);

        $start = $cursor;
        $hasVarPrefix = false;
        while ($start > 0) {
            $char = \mb_substr($text, $start - 1, 1);
            if (\ctype_space($char)) {
                break;
            }
            if ($char === '>' && $start >= 2 && \mb_substr($text, $start - 2, 1) === '-') {
                break;
            }
            if ($char === ':' && $start >= 2 && \mb_substr($text, $start - 2, 1) === ':') {
                break;
            }
            if ($char === '$') {
                $hasVarPrefix = true;
                $start--;
                break;
            }
            $start--;
        }

        $end = $cursor;
        while ($end < $textLen) {
            $char = \mb_substr($text, $end, 1);
            if (\ctype_space($char) || (!\ctype_alnum($char) && $char !== '_')) {
                break;
            }
            $end++;
        }

        if ($hasVarPrefix && $match[0] !== '$') {
            $match = '$'.$match;
        }

        $addParens = $this->smartBrackets && $this->shouldAddParentheses($match);

        $before = \mb_substr($text, 0, $start);
        $after = \mb_substr($text, $end);

        if ($addParens) {
            $buffer->setText($before.$match.'()'.$after);
            // Place cursor after parens for zero-arg functions, inside for others
            $offset = $this->functionHasParameters($match) ? 1 : 2;
            $buffer->setCursor($start + \mb_strlen($match) + $offset);
        } else {
            $buffer->setText($before.$match.$after);
            $buffer->setCursor($start + \mb_strlen($match));
        }
    }

    /**
     * Determine if parentheses should be added for this completion.
     */
    private function shouldAddParentheses(string $match): bool
    {
        if (\substr($match, 0, 1) === '$') {
            return false;
        }

        if (\strtoupper($match) === $match && \ctype_upper($match[0])) {
            return false;
        }

        return \function_exists($match);
    }

    /**
     * Check whether a function accepts any parameters (required or optional).
     */
    private function functionHasParameters(string $name): bool
    {
        try {
            return (new \ReflectionFunction($name))->getNumberOfParameters() > 0;
        } catch (\ReflectionException $e) {
            // If we can't reflect it, assume it has parameters
            return true;
        }
    }

    /**
     * Handle interactive selection with arrow keys and filtering.
     */
    private function handleInteractiveSelection(Buffer $buffer, Terminal $terminal, Readline $readline): bool
    {
        if ($this->interactiveSelectionEnabled !== null) {
            $isTTY = $this->interactiveSelectionEnabled;
        } else {
            $isTTY = @\stream_isatty(\STDIN);
        }

        if (!$isTTY) {
            $this->inInteractiveMode = false;

            return true;
        }

        while ($this->inInteractiveMode) {
            $key = $readline->readNextKey();
            $keyStr = (string) $key;
            $keyValue = $key->getValue();

            if ($keyStr === 'escape:[A' || $keyValue === "\x10") { // Up or Ctrl-P
                $this->moveSelection(-1, $readline);
                $this->updateOverlay($buffer, $terminal, $readline);
            } elseif ($keyStr === 'escape:[B' || $keyValue === "\x0e") { // Down or Ctrl-N
                $this->moveSelection(1, $readline);
                $this->updateOverlay($buffer, $terminal, $readline);
            } elseif ($keyStr === 'escape:[C' || $keyValue === "\x06") { // Right or Ctrl-F
                $this->moveSelectionHorizontal(1, $readline);
                $this->updateOverlay($buffer, $terminal, $readline);
            } elseif ($keyStr === 'escape:[D' || $keyValue === "\x02") { // Left or Ctrl-B
                $this->moveSelectionHorizontal(-1, $readline);
                $this->updateOverlay($buffer, $terminal, $readline);
            } elseif ($keyValue === "\t") {
                // Tab cycles to the next match
                $this->moveSelection(1, $readline);
                $this->updateOverlay($buffer, $terminal, $readline);
            } elseif ($keyValue === "\r" || $keyValue === "\n") {
                $this->inInteractiveMode = false;
                if (!empty($this->filteredMatches)) {
                    $selected = $this->filteredMatches[$this->selectedIndex];
                    $this->insertMatch($buffer, $selected);
                }
                $readline->clearOverlay($buffer);
                break;
            } elseif ($keyValue === "\e" || $keyValue === "\x03") { // Escape or Ctrl-C
                $this->inInteractiveMode = false;
                $readline->clearOverlay($buffer);
                break;
            } elseif ($keyValue === "\x7f" || $keyValue === "\x08") { // Backspace
                if ($buffer->getCursor() > 0) {
                    $buffer->deleteBackward();

                    $this->filterText = CurrentWord::extract($buffer->getText(), $buffer->getCursor());

                    if ($this->filterText === '') {
                        $this->inInteractiveMode = false;
                        $readline->clearOverlay($buffer);
                        break;
                    }

                    $this->updateFilter($terminal);
                    $this->updateOverlay($buffer, $terminal, $readline);
                }
            } elseif ($key->isChar() && \strlen($keyValue) === 1 && $keyValue >= ' ' && $keyValue <= '~') {
                $buffer->insert($keyValue);

                if ($keyValue === ' ') {
                    $this->inInteractiveMode = false;
                    $readline->clearOverlay($buffer);
                    break;
                }

                $this->filterText = CurrentWord::extract($buffer->getText(), $buffer->getCursor());
                $this->updateFilter($terminal);
                $this->updateOverlay($buffer, $terminal, $readline);
            } else {
                $this->inInteractiveMode = false;
                $readline->clearOverlay($buffer);
                $readline->replayKey($key);
                break;
            }
        }

        return true;
    }

    /**
     * Generate overlay lines from matches and render the frame.
     */
    private function updateOverlay(Buffer $buffer, Terminal $terminal, Readline $readline): void
    {
        // Terminal width may change while the menu is open.
        $this->updateLayout($terminal);

        $maxRows = $this->getMaxRows($readline);
        if ($maxRows === null) {
            $this->scrollOffset = 0;
        } else {
            $maxOffset = \max(0, $this->totalRows - $maxRows);
            $this->scrollOffset = \max(0, \min($this->scrollOffset, $maxOffset));
        }

        $renderer = new CompletionRenderer($terminal);
        $lines = $renderer->render($this->filteredMatches, $this->selectedIndex, $maxRows, $this->scrollOffset, !$this->expanded);

        // Prepend a blank separator line above the menu
        $readline->renderOverlay($buffer, \array_merge([''], $lines));
    }

    /**
     * Update filtered matches based on current filter text.
     */
    private function updateFilter(?Terminal $terminal = null): void
    {
        $previousSelection = $this->filteredMatches[$this->selectedIndex] ?? null;

        if ($this->filterText === '') {
            $this->filteredMatches = $this->currentMatches;
        } else {
            $this->filteredMatches = FuzzyMatcher::filter($this->filterText, $this->currentMatches);
        }

        // Keep the same item selected if it survived filtering, otherwise
        // reset to the top where the best matches are.
        $newIndex = \array_search($previousSelection, $this->filteredMatches, true);
        $this->selectedIndex = $newIndex !== false ? $newIndex : 0;

        // Reset viewport when filter changes
        $this->scrollOffset = 0;
        $this->expanded = false;

        if ($terminal !== null) {
            $this->updateLayout($terminal);
        }
    }

    /**
     * Recalculate total row count for the current matches.
     */
    private function updateLayout(Terminal $terminal): void
    {
        if (empty($this->filteredMatches)) {
            $this->totalRows = 0;
            $this->totalColumns = 0;

            return;
        }

        $renderer = new CompletionRenderer($terminal);
        $layout = $renderer->calculateLayout($this->filteredMatches);
        $this->totalRows = $layout['rows'];
        $this->totalColumns = $layout['columns'];
    }

    /**
     * Get the maximum visible rows for the current viewport state.
     *
     * Returns null if all rows fit without truncation.
     */
    private function getMaxRows(Readline $readline): ?int
    {
        $available = $readline->getOverlayAvailableRows(!$this->expanded);

        // Account for the blank separator line above the menu
        $menuBudget = \max(1, $available - 1);

        if ($this->totalRows <= $menuBudget) {
            // No truncation needed if everything fits (including the +1 off-by-one:
            // don't show a status line when the last row would fit in its place)
            return null;
        }

        // When truncated, reserve one row for the status line
        return \max(1, $menuBudget - 1);
    }

    /**
     * Move selection up or down, adjusting viewport as needed.
     */
    private function moveSelection(int $delta, Readline $readline): void
    {
        $count = \count($this->filteredMatches);
        if ($count === 0) {
            return;
        }

        $newIndex = $this->selectedIndex + $delta;

        // Wrap around
        if ($newIndex < 0) {
            $newIndex = $count - 1;
        } elseif ($newIndex >= $count) {
            $newIndex = 0;
        }

        $this->selectedIndex = $newIndex;
        $this->adjustViewport($readline);
    }

    /**
     * Adjust scroll offset so the selected item is visible.
     */
    private function adjustViewport(Readline $readline): void
    {
        $maxRows = $this->getMaxRows($readline);

        // Everything fits, no scrolling needed
        if ($maxRows === null) {
            $this->scrollOffset = 0;

            return;
        }

        $selectedRow = $this->selectedIndex % $this->totalRows;

        // Selection is above the visible window
        if ($selectedRow < $this->scrollOffset) {
            $this->scrollOffset = $selectedRow;

            return;
        }

        // Selection is below the visible window
        if ($selectedRow >= $this->scrollOffset + $maxRows) {
            if (!$this->expanded) {
                $this->expanded = true;
                $maxRows = $this->getMaxRows($readline);

                // After expanding, everything might fit
                if ($maxRows === null) {
                    $this->scrollOffset = 0;

                    return;
                }
            }

            // Scroll to keep selection visible at the bottom
            if ($selectedRow >= $this->scrollOffset + $maxRows) {
                $this->scrollOffset = $selectedRow - $maxRows + 1;
            }
        }

        // Clamp scroll offset
        $this->scrollOffset = \max(0, \min($this->scrollOffset, $this->totalRows - $maxRows));
    }

    /**
     * Move selection left or right (horizontal between columns).
     */
    private function moveSelectionHorizontal(int $delta, Readline $readline): void
    {
        $count = \count($this->filteredMatches);
        if ($count === 0) {
            return;
        }

        $rows = $this->totalRows;
        $columns = $this->totalColumns;

        $currentRow = $this->selectedIndex % $rows;
        $currentCol = \intdiv($this->selectedIndex, $rows);

        $newCol = $currentCol + $delta;

        // Wrap to next/previous row at column boundaries
        if ($newCol >= $columns || ($currentRow + ($newCol * $rows)) >= $count) {
            $newCol = 0;
            $currentRow++;
            if ($currentRow >= $rows) {
                $currentRow = 0;
            }
        } elseif ($newCol < 0) {
            $newCol = $columns - 1;
            $currentRow--;
            if ($currentRow < 0) {
                $currentRow = $rows - 1;
            }
        }

        $newIndex = $currentRow + ($newCol * $rows);

        // If we landed past the end (sparse last row), fall back to last column on this row
        if ($newIndex >= $count) {
            while ($newCol > 0 && $newIndex >= $count) {
                $newCol--;
                $newIndex = $currentRow + ($newCol * $rows);
            }
        }

        $this->selectedIndex = $newIndex;
        $this->adjustViewport($readline);
    }

    /**
     * Get the common prefix of all matches.
     *
     * @todo Should this be grapheme cluster aware?
     *
     * @param string[] $matches
     */
    private function getCommonPrefix(array $matches): string
    {
        if (empty($matches)) {
            return '';
        }

        $first = \array_shift($matches);
        $prefix = '';

        for ($i = 0; $i < \strlen($first); $i++) {
            $char = $first[$i];
            foreach ($matches as $match) {
                if (!isset($match[$i]) || $match[$i] !== $char) {
                    return $prefix;
                }
            }
            $prefix .= $char;
        }

        return $prefix;
    }
}
