<?php declare(strict_types=1);

namespace Kilo;

use ReflectionClass;

trait MagicProperties {
	abstract public function __get(string $name);

	public function __set(string $name, $value)
	{
		if (property_exists($this, $name))
		{
			$this->$name = $value;
		}
	}

	public function __isset(string $name): bool
	{
		return isset($this->$name);
	}
}

class Key {
	public const ARROW_DOWN = 'ARROW_DOWN';
	public const ARROW_LEFT = 'ARROW_LEFT';
	public const ARROW_RIGHT = 'ARROW_RIGHT';
	public const ARROW_UP = 'ARROW_UP';
	public const BACKSPACE = 'BACKSPACE';
	public const DEL_KEY = 'DELETE';
	public const END_KEY = 'END';
	public const ENTER = 'ENTER';
	public const ESCAPE = 'ESCAPE';
	public const HOME_KEY = 'HOME';
	public const PAGE_DOWN = 'PAGE_DOWN';
	public const PAGE_UP = 'PAGE_UP';

	public static function getConstList(): array
	{
		return (new ReflectionClass(static::class))->getConstants();
	}
}

class Highlight {
	public const NORMAL = 0;
	public const NUMBER = 1;
	public const MATCH = 2;
}

function syntax_to_color(int $hl): int
{
	switch ($hl)
	{
		case Highlight::NUMBER:
			return 31; // Foreground Red

		case Highlight::MATCH:
			return 34; // Foreground Blue

		default:
			return 37; // Foreground White
	}
}

/**
 * @property-read int size
 * @property-read int rsize
 */
class Row {
	use MagicProperties;

	public string $chars = '';
	public string $render = '';

	public array $hl = [];

	public static function new(string $chars): self
	{
		return new self($chars);
	}

	private function __construct($chars)
	{
		$this->chars = $chars;
	}

	public function __get(string $name)
	{
		switch ($name)
		{
			case 'size':
				return strlen($this->chars);

			case 'rsize':
				return strlen($this->render);

			default:
				return NULL;
		}
	}

	public function __toString(): string
	{
		return $this->chars . "\n";
	}

	public function update(): void
	{
		$idx = 0;

		for ($i = 0; $i < $this->size; $i++)
		{
			if ($this->chars[$i] === "\t")
			{
				$this->render[$idx++] = ' ';
				while ($idx % KILO_TAB_STOP !== 0)
				{
					$this->render[$idx++] = ' ';
				}
			}
			else
			{
				$this->render[$idx++] = $this->chars[$i];
			}
		}

		$this->updateSyntax();
	}

	// ------------------------------------------------------------------------
	// ! Syntax Highlighting
	// ------------------------------------------------------------------------

	protected function updateSyntax(): void
	{
		$this->hl = array_fill(0, $this->rsize, Highlight::NORMAL);

		for ($i = 0; $i < $this->rsize; $i++)
		{
			if (is_digit($this->render[$i]))
			{
				$this->hl[$i] = Highlight::NUMBER;
			}
		}
	}
}

/**
 * @property-read int numRows
 */
class Editor {
	use MagicProperties;

	private string $ab = '';

	protected int $cursorX = 0;
	protected int $cursorY = 0;
	protected int $renderX = 0;
	protected int $rowOffset = 0;
	protected int $colOffset = 0;
	protected int $screenRows = 0;
	protected int $screenCols = 0;

	/**
	 * Array of Row objects
	 */
	protected array $rows = [];

	protected int $dirty = 0;
	protected string $filename = '';
	protected string $statusMsg = '';
	protected int $statusMsgTime;

	public static function new(): Editor
	{
		return new self();
	}

	private function __construct()
	{
		$this->statusMsgTime = time();

		[$this->screenRows, $this->screenCols] = get_window_size();

		$this->screenRows -= 2;

		// print_r($this); die();
	}

	public function __get(string $name)
	{
		if ($name === 'numRows')
		{
			return count($this->rows);
		}

		return NULL;
	}

	// ------------------------------------------------------------------------
	// ! Terminal
	// ------------------------------------------------------------------------
	protected function readKey(): string
	{
		$c = read_stdin();

		// @TODO Make this more DRY
		switch ($c)
		{
			case "\x7f": return Key::BACKSPACE;

			case "\r": return Key::ENTER;

			case "\x1b[A": return Key::ARROW_UP;
			case "\x1b[B": return Key::ARROW_DOWN;
			case "\x1b[C": return Key::ARROW_RIGHT;
			case "\x1b[D": return Key::ARROW_LEFT;

			case "\x1b[3~": return Key::DEL_KEY;

			case "\x1b[5~": return Key::PAGE_UP;
			case "\x1b[6~": return Key::PAGE_DOWN;

			case "\x1bOH":
			case "\x1b[1~":
			case "\x1b[7~":
			case "\x1b[H":
				return Key::HOME_KEY;

			case "\x1bOF":
			case "\x1b[4~":
			case "\x1b[8~":
			case "\x1b[F":
				return Key::END_KEY;

			case "\x1b":
				return Key::ESCAPE;

			default: return $c;
		}
	}

	// ------------------------------------------------------------------------
	// ! Row Operations
	// ------------------------------------------------------------------------

	protected function rowCxToRx(Row $row, int $cx): int
	{
		$rx = 0;
		for ($i = 0; $i < $cx; $i++)
		{
			if ($row->chars[$i] === "\t")
			{
				$rx += (KILO_TAB_STOP - 1) - ($rx % KILO_TAB_STOP);
			}
			$rx++;
		}

		return $rx;
	}

	protected function rowRxToCx(Row $row, int $rx): int
	{
		$cur_rx = 0;
		for ($cx = 0; $cx < $row->size; $cx++)
		{
			if ($row->chars[$cx] === "\t")
			{
				$cur_rx += (KILO_TAB_STOP - 1) - ($cur_rx % KILO_TAB_STOP);
			}
			$cur_rx++;

			if ($cur_rx > $rx)
			{
				return $cx;
			}
		}

		return $cx;
	}

	protected function insertRow(int $at, string $s): void
	{
		if ($at < 0 || $at > $this->numRows)
		{
			return;
		}

		$row = Row::new($s);

		if ($at === $this->numRows)
		{
			$this->rows[] = $row;
		}
		else
		{
			$this->rows = [
				...array_slice($this->rows, 0, $at),
				$row,
				...array_slice($this->rows, $at),
			];
		}

		$this->rows[$at]->update();

		$this->dirty++;
	}

	protected function deleteRow(int $at): void
	{
		if ($at < 0 || $at >= $this->numRows)
		{
			return;
		}

		// Remove the row
		unset($this->rows[$at]);

		// Re-index the array of rows
		$this->rows = array_values($this->rows);

		$this->dirty++;
	}

	protected function rowInsertChar(Row $row, int $at, string $c): void
	{
		if ($at < 0 || $at > $row->size)
		{
			$at = $row->size;
		}

		// Safely insert into arbitrary position in the existing string
		$row->chars = substr($row->chars, 0, $at) . $c . substr($row->chars, $at);
		$row->update();
		$this->dirty++;
	}

	protected function rowAppendString(Row $row, string $s): void
	{
		$row->chars .= $s;
		$row->update();
		$this->dirty++;
	}

	protected function rowDeleteChar(Row $row, int $at): void
	{
		if ($at < 0 || $at >= $row->size)
		{
			return;
		}

		$row->chars = substr_replace($row->chars, '', $at, 1);
		$row->update();
		$this->dirty++;
	}

	// ------------------------------------------------------------------------
	// ! Editor Operations
	// ------------------------------------------------------------------------

	protected function insertChar(string $c): void
	{
		if ($this->cursorY === $this->numRows)
		{
			$this->insertRow($this->numRows, '');
		}
		$this->rowInsertChar($this->rows[$this->cursorY], $this->cursorX, $c);
		$this->cursorX++;
	}

	protected function insertNewline(): void
	{
		if ($this->cursorX === 0)
		{
			$this->insertRow($this->cursorY, '');
		}
		else
		{
			$row = $this->rows[$this->cursorY];

			// Add a new row, with the contents from the cursor to the end of the line
			$this->insertRow($this->cursorY + 1, substr($row->chars, $this->cursorX));

			// Update the (now previous) row
			$row->chars = substr($row->chars, 0, $this->cursorX);
			$row->update();
		}

		$this->cursorY++;
		$this->cursorX = 0;
	}

	protected function deleteChar(): void
	{
		if ($this->cursorY === $this->numRows || ($this->cursorX === 0 && $this->cursorY === 0))
		{
			return;
		}

		$row = $this->rows[$this->cursorY];
		if ($this->cursorX > 0)
		{
			$this->rowDeleteChar($row, $this->cursorX - 1);
			$this->cursorX--;
		}
		else
		{
			$this->cursorX = $this->rows[$this->cursorY - 1]->size;
			$this->rowAppendString($this->rows[$this->cursorY -1], $row->chars);
			$this->deleteRow($this->cursorY);
			$this->cursorY--;
		}
	}

	// ------------------------------------------------------------------------
	// ! File I/O
	// ------------------------------------------------------------------------

	protected function rowsToString(): string
	{
		$lines = [];
		foreach ($this->rows as $row)
		{
			$lines[] = (string)$row;
		}

		return implode('', $lines);
	}

	public function open(string $filename): void
	{
		// Copy filename for display
		$this->filename = $filename;

		// Determine the full path to the file
		/* $baseFile = basename($filename);
		$basePath = str_replace($baseFile, '', $filename);
		$path = (is_dir($basePath)) ? $basePath : getcwd();

		$fullname = $path . '/' . $baseFile; */

		// #TODO gracefully handle issues with loading a file
		$handle = fopen($filename, 'rb');
		if ($handle === FALSE)
		{
			write_stdout("\x1b[2J"); // Clear the screen
			write_stdout("\x1b[H"); // Reposition cursor to top-left
			disableRawMode();
			print_r(error_get_last());
			die();
		}

		while (($line = fgets($handle)) !== FALSE)
		{
			// Remove line endings when reading the file
			$this->insertRow($this->numRows, rtrim($line));
		}

		fclose($handle);

		$this->dirty = 0;
	}

	protected function save(): void
	{
		if ($this->filename === '')
		{
			$newFilename = $this->prompt('Save as: %s');
			if ($newFilename === '')
			{
				$this->setStatusMessage('Save aborted');
				return;
			}

			$this->filename = $newFilename;
		}

		$contents = $this->rowsToString();

		$res = file_put_contents($this->filename, $contents);
		if ($res === strlen($contents))
		{
			$this->setStatusMessage('%d bytes written to disk', strlen($contents));
			$this->dirty = 0;
			return;
		}

		$this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message']);
	}

	// ------------------------------------------------------------------------
	// ! Find
	// ------------------------------------------------------------------------

	protected function findCallback(string $query, string $key): void
	{
		static $lastMatch = -1;
		static $direction = 1;

		if ($key === "\r" || $key === "\x1b")
		{
			$lastMatch = -1;
			$direction = 1;
			return;
		}

		if ($key === Key::ARROW_RIGHT || $key === Key::ARROW_DOWN)
		{
			$direction = 1;
		}
		else if ($key === Key::ARROW_LEFT || $key === Key::ARROW_UP)
		{
			$direction = -1;
		}
		else
		{
			$lastMatch = -1;
			$direction = 1;
		}

		if ($lastMatch === -1)
		{
			$direction = 1;
		}
		$current = $lastMatch;

		for ($i = 0; $i < $this->numRows; $i++)
		{
			$current += $direction;
			if ($current === -1)
			{
				$current = $this->numRows - 1;
			}
			else if ($current === $this->numRows)
			{
				$current = 0;
			}

			$match = strpos($this->rows[$current]->render, $query);
			if ($match !== FALSE)
			{
				$lastMatch = $current;
				$this->cursorY = $current;
				$this->cursorX = $this->rowRxToCx($this->rows[$current], $match);
				$this->rowOffset = $this->numRows;
				break;
			}
		}
	}

	protected function find(): void
	{
		$savedCx = $this->cursorX;
		$savedCy = $this->cursorY;
		$savedColOff = $this->colOffset;
		$savedRowOff = $this->rowOffset;

		$query = $this->prompt('Search: %s (Use ESC/Arrows/Enter)', [$this, 'findCallback']);

		// If they pressed escape, the query will be empty,
		// restore original cursor and scroll locations
		if ($query === '')
		{
			$this->cursorX = $savedCx;
			$this->cursorY = $savedCy;
			$this->colOffset = $savedColOff;
			$this->rowOffset = $savedRowOff;
		}
	}

	// ------------------------------------------------------------------------
	// ! Output
	// ------------------------------------------------------------------------

	protected function scroll(): void
	{
		$this->renderX = 0;
		if ($this->cursorY < $this->numRows)
		{
			$this->renderX = $this->rowCxToRx($this->rows[$this->cursorY], $this->cursorX);
		}

		// Vertical Scrolling
		if ($this->cursorY < $this->rowOffset)
		{
			$this->rowOffset = $this->cursorY;
		}
		if ($this->cursorY >= ($this->rowOffset + $this->screenRows))
		{
			$this->rowOffset = $this->cursorY - $this->screenRows + 1;
		}

		// Horizontal Scrolling
		if ($this->renderX < $this->colOffset)
		{
			$this->colOffset = $this->renderX;
		}
		if ($this->renderX >= ($this->colOffset + $this->screenCols))
		{
			$this->colOffset = $this->renderX - $this->screenCols + 1;
		}
	}

	protected function drawRows(): void
	{
		for ($y = 0; $y < $this->screenRows; $y++)
		{
			$filerow = $y + $this->rowOffset;
			if ($filerow >= $this->numRows)
			{
				if ($this->numRows === 0 && $y === $this->screenRows / 3)
				{
					$welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION);
					$welcomelen = strlen($welcome);
					if ($welcomelen > $this->screenCols)
					{
						$welcomelen = $this->screenCols;
					}

					$padding = ($this->screenCols - $welcomelen) / 2;
					if ($padding > 0)
					{
						$this->ab .= '~';
						$padding--;
					}
					for (; $padding >= 0; $padding--)
					{
						$this->ab .= ' ';
					}

					$this->ab .= substr($welcome, 0, $welcomelen);
				}
				else
				{
					$this->ab .= '~';
				}
			}
			else
			{
				$len = $this->rows[$filerow]->rsize - $this->colOffset;
				if ($len < 0)
				{
					$len = 0;
				}
				if ($len > $this->screenCols)
				{
					$len = $this->screenCols;
				}

				$c = substr($this->rows[$filerow]->render, $this->colOffset, $len);
				$hl = array_slice($this->rows[$filerow]->hl, $this->colOffset, $len);

				$currentColor = -1;

				for ($i = 0; $i < $len; $i++)
				{
					if ($hl[$i] === Highlight::NORMAL)
					{
						if ($currentColor !== -1)
						{
							$this->ab .= "\x1b[39m";
							$currentColor = -1;
						}
						$this->ab .= $c[$i];
					}
					else
					{
						$color = syntax_to_color($hl[$i]);
						if ($color !== $currentColor)
						{
							$currentColor = $color;
							$this->ab .= sprintf("\x1b[%dm", $color);
						}
						$this->ab .= $c[$i];
					}
				}

				$this->ab .= "\x1b[39m";
			}

			$this->ab .= "\x1b[K"; // Clear the current line
			$this->ab .= "\r\n";
		}
	}

	protected function drawStatusBar(): void
	{
		$this->ab .= "\x1b[7m";

		$statusFilename = $this->filename !== '' ? $this->filename : '[No Name]';
		$isDirty = ($this->dirty > 0) ? '(modified)' : '';
		$status = sprintf('%.20s - %d lines %s', $statusFilename, $this->numRows, $isDirty);
		$rstatus = sprintf('%d/%d', $this->cursorY + 1, $this->numRows);
		$len = strlen($status);
		$rlen = strlen($rstatus);
		if ($len > $this->screenCols)
		{
			$len = $this->screenCols;
		}
		$this->ab .= substr($status, 0, $len);
		while ($len < $this->screenCols)
		{
			if ($this->screenCols - $len === $rlen)
			{
				$this->ab .= substr($rstatus, 0, $rlen);
				break;
			}

			$this->ab .= ' ';
			$len++;
		}
		$this->ab .= "\x1b[m";
		$this->ab .= "\r\n";
	}

	protected function drawMessageBar(): void
	{
		$this->ab .= "\x1b[K";
		$len = strlen($this->statusMsg);
		if ($len > $this->screenCols)
		{
			$len = $this->screenCols;
		}

		if ($len > 0 && (time() - $this->statusMsgTime) < 5)
		{
			$this->ab .= substr($this->statusMsg, 0, $len);
		}
	}

	public function refreshScreen(): void
	{
		$this->scroll();

		$this->ab = '';

		$this->ab .= "\x1b[?25l"; // Hide the cursor
		$this->ab .= "\x1b[H"; // Reposition cursor to top-left

		$this->drawRows();
		$this->drawStatusBar();
		$this->drawMessageBar();

		// Specify the current cursor position
		$this->ab .= sprintf("\x1b[%d;%dH",
			($this->cursorY - $this->rowOffset) + 1,
			($this->renderX - $this->colOffset) + 1
		);

		$this->ab .= "\x1b[?25h"; // Show the cursor

		echo $this->ab;
	}

	public function setStatusMessage(string $fmt, ...$args): void
	{
		$this->statusMsg = (count($args) > 0)
			? sprintf($fmt, ...$args)
			: $fmt;
		$this->statusMsgTime = time();
	}

	// ------------------------------------------------------------------------
	// ! Input
	// ------------------------------------------------------------------------

	protected function prompt(string $prompt, ?callable $callback = NULL): string
	{
		$buffer = '';
		$modifiers = Key::getConstList();
		while (TRUE)
		{
			$this->setStatusMessage($prompt, $buffer);
			$this->refreshScreen();

			$c = $this->readKey();
			$cord = ord($c);

			if ($c === Key::ESCAPE)
			{
				$this->setStatusMessage('');
				if ($callback !== NULL)
				{
					$callback($buffer, $c);
				}
				return '';
			}

			if ($c === Key::ENTER && $buffer !== '')
			{
				$this->setStatusMessage('');
				if ($callback !== NULL)
				{
					$callback($buffer, $c);
				}
				return $buffer;
			}

			if ($c === Key::DEL_KEY  || $c === Key::BACKSPACE || $c === chr(ctrl_key('h')))
			{
				$buffer = substr($buffer, 0, -1);
			}
			else if (is_ascii($c) && ( ! is_cntrl($c)) && ! in_array($c, $modifiers, TRUE))
			{
				$buffer .= $c;
			}

			if ($callback !== NULL)
			{
				$callback($buffer, $c);
			}
		}
	}

	protected function moveCursor(string $key): void
	{
		$row = ($this->cursorY >= $this->numRows)
			? NULL
			: $this->rows[$this->cursorY];

		switch ($key)
		{
			case Key::ARROW_LEFT:
				if ($this->cursorX !== 0)
				{
					$this->cursorX--;
				}
				else if ($this->cursorX > 0)
				{
					$this->cursorY--;
					$this->cursorX = $this->rows[$this->cursorY]->size;
				}
			break;

			case Key::ARROW_RIGHT:
				if ($row && $this->cursorX < $row->size)
				{
					$this->cursorX++;
				}
				else if ($row && $this->cursorX === $row->size)
				{
					$this->cursorY++;
					$this->cursorX = 0;
				}
			break;

			case Key::ARROW_UP:
				if ($this->cursorY !== 0)
				{
					$this->cursorY--;
				}
			break;

			case Key::ARROW_DOWN:
				if ($this->cursorY < $this->numRows)
				{
					$this->cursorY++;
				}
			break;
		}

		$row = ($this->cursorY >= $this->numRows)
			? NULL
			: $this->rows[$this->cursorY];
		$rowlen = $row ? $row->size : 0;
		if ($this->cursorX > $rowlen)
		{
			$this->cursorX = $rowlen;
		}
	}

	public function processKeypress(): ?string
	{
		static $quit_times = KILO_QUIT_TIMES;

		$c = $this->readKey();

		if ($c === "\0")
		{
			return '';
		}

		switch ($c)
		{
			case Key::ENTER:
				$this->insertNewline();
			break;

			case chr(ctrl_key('q')):
				if ($this->dirty > 0 && $quit_times > 0)
				{
					$this->setStatusMessage('WARNING!!! File has unsaved changes.' .
						'Press Ctrl-Q %d more times to quit.', $quit_times);
					$quit_times--;
					return '';
				}
				write_stdout("\x1b[2J"); // Clear the screen
				write_stdout("\x1b[H"); // Reposition cursor to top-left
				return NULL;
			break;

			case chr(ctrl_key('s')):
				$this->save();
			break;

			case Key::HOME_KEY:
				$this->cursorX = 0;
			break;

			case Key::END_KEY:
				if ($this->cursorY < $this->numRows)
				{
					$this->cursorX = $this->rows[$this->cursorY]->size - 1;
				}
			break;

			case chr(ctrl_key('f')):
				$this->find();
			break;

			case Key::BACKSPACE:
			case chr(ctrl_key('h')):
			case Key::DEL_KEY:
				if ($c === Key::DEL_KEY)
				{
					$this->moveCursor(Key::ARROW_RIGHT);
				}
				$this->deleteChar();
			break;

			case Key::PAGE_UP:
			case Key::PAGE_DOWN:
				$this->pageUpOrDown($c);
			break;

			case Key::ARROW_UP:
			case Key::ARROW_DOWN:
			case Key::ARROW_LEFT:
			case Key::ARROW_RIGHT:
				$this->moveCursor($c);
			break;

			case chr(ctrl_key('l')):
			case Key::ESCAPE:
				// Do nothing
			break;

			default:
				$this->insertChar($c);
			break;
		}

		$quit_times = KILO_QUIT_TIMES;

		return $c;
	}

	private function pageUpOrDown(string $c): void
	{
		if ($c === Key::PAGE_UP)
		{
			$this->cursorY = $this->rowOffset;
		}
		else if ($c === Key::PAGE_DOWN)
		{
			$this->cursorY = $this->rowOffset + $this->screenRows - 1;
			if ($this->cursorY > $this->numRows)
			{
				$this->cursorY = $this->numRows;
			}
		}

		$times = $this->screenRows;
		for (; $times > 0; $times--)
		{
			$this->moveCursor($c === Key::PAGE_UP ? Key::ARROW_UP : Key::ARROW_DOWN);
		}
	}
}