import '@common/assets/fonts/vga.font.css';
import { randInt } from "@common/utils";
import { createCanvas } from './canvas';
import type { Point, Rect } from '@common/geometry';
import { bresenhamCircleGen, bresenhamLineGen, type BresenhamCircleOptions, type BresenhamLineOptions } from '@common/navigation/bresenham';
import Input from '@common/input';

export type ColorLike = string | number | Color;
export type Char = [string, ColorLike?, ColorLike?] | string;
export type DefinedChar = [string, ColorLike, ColorLike];

export const randChar = (min = ' ', max = '~') =>
    String.fromCharCode(randInt(
        min.charCodeAt(0),
        max.charCodeAt(0) + 1,
    ));

const generateColors = () => {
    const colors: string[] = [];
    for (let i = 0; i < 16; i++) {
        const h = (i & 0b1000) ? 'ff' : '7f';

        const r = (i & 0b0100) ? h : '00';
        const g = (i & 0b0010) ? h : '00';
        const b = (i & 0b0001) ? h : '00';

        const color = `#${r}${g}${b}`;
        colors.push(color);
    }

    return colors;
};

const COLORS = generateColors();

export const GAME_WIDTH = 80;
export const GAME_HEIGHT = 25;

export enum Color {
    BLACK = 0b0000,
    DARK_BLUE = 0b0001,
    DARK_CYAN = 0b0011,
    DARK_GREEN = 0b0010,
    DARK_YELLOW = 0b0110,
    DARK_RED = 0b0100,
    DARK_MAGENTA = 0b0101,
    GRAY = 0b0111,
    BLUE = 0b1001,
    CYAN = 0b1011,
    GREEN = 0b1010,
    YELLOW = 0b1110,
    RED = 0b1100,
    MAGENTA = 0b1101,
    WHITE = 0b1111,
}

export const DEFAULT_FG = Color.WHITE;
export const DEFAULT_BG = Color.BLACK;

export enum Glyphs {
    // Pure single-line box drawing
    SINGLE_HORIZONTAL = '─',
    SINGLE_VERTICAL = '│',
    SINGLE_TOP_LEFT = '┌',
    SINGLE_TOP_RIGHT = '┐',
    SINGLE_BOTTOM_LEFT = '└',
    SINGLE_BOTTOM_RIGHT = '┘',
    SINGLE_TEE_RIGHT = '├',
    SINGLE_TEE_LEFT = '┤',
    SINGLE_TEE_DOWN = '┬',
    SINGLE_TEE_UP = '┴',
    SINGLE_CROSS = '┼',

    // Pure double-line box drawing
    DOUBLE_HORIZONTAL = '═',
    DOUBLE_VERTICAL = '║',
    DOUBLE_TOP_LEFT = '╔',
    DOUBLE_TOP_RIGHT = '╗',
    DOUBLE_BOTTOM_LEFT = '╚',
    DOUBLE_BOTTOM_RIGHT = '╝',
    DOUBLE_TEE_RIGHT = '╠',
    DOUBLE_TEE_LEFT = '╣',
    DOUBLE_TEE_DOWN = '╦',
    DOUBLE_TEE_UP = '╩',
    DOUBLE_CROSS = '╬',

    // Mixed single/double combinations (_DBL_H = double horizontal, _DBL_V = double vertical)
    MIX_TOP_LEFT_DBL_H = '╒',
    MIX_TOP_LEFT_DBL_V = '╓',
    MIX_TOP_RIGHT_DBL_H = '╕',
    MIX_TOP_RIGHT_DBL_V = '╖',
    MIX_BOTTOM_LEFT_DBL_H = '╘',
    MIX_BOTTOM_LEFT_DBL_V = '╙',
    MIX_BOTTOM_RIGHT_DBL_H = '╛',
    MIX_BOTTOM_RIGHT_DBL_V = '╜',
    MIX_TEE_RIGHT_DBL_H = '╞',
    MIX_TEE_RIGHT_DBL_V = '╟',
    MIX_TEE_LEFT_DBL_H = '╡',
    MIX_TEE_LEFT_DBL_V = '╢',
    MIX_TEE_DOWN_DBL_H = '╤',
    MIX_TEE_DOWN_DBL_V = '╥',
    MIX_TEE_UP_DBL_H = '╧',
    MIX_TEE_UP_DBL_V = '╨',
    MIX_CROSS_DBL_H = '╪',
    MIX_CROSS_DBL_V = '╫',

    // Block elements
    SPACE = ' ',
    LIGHT_SHADE = '░',
    MEDIUM_SHADE = '▒',
    DARK_SHADE = '▓',
    FULL_BLOCK = '█',
    UPPER_HALF_BLOCK = '▀',
    LOWER_HALF_BLOCK = '▄',
    LEFT_HALF_BLOCK = '▌',
    RIGHT_HALF_BLOCK = '▐',

    // Arrows
    ARROW_LEFT = '←',
    ARROW_UP = '↑',
    ARROW_RIGHT = '→',
    ARROW_DOWN = '↓',
    ARROW_HORIZONTAL = '↔',
    ARROW_VERTICAL = '↕',
    TRIANGLE_UP = '▲',
    TRIANGLE_RIGHT = '►',
    TRIANGLE_DOWN = '▼',
    TRIANGLE_LEFT = '◄',

    // Symbols
    SMILEY = '☺',
    SMILEY_FILLED = '☻',
    FEMALE = '♀',
    MALE = '♂',
    SPADE = '♠',
    CLUB = '♣',
    HEART = '♥',
    DIAMOND = '♦',
}

export const isVertical = (char: string) => char === Glyphs.SINGLE_VERTICAL || char === Glyphs.DOUBLE_VERTICAL;
export const isHorizontal = (char: string) => char === Glyphs.SINGLE_HORIZONTAL || char === Glyphs.DOUBLE_HORIZONTAL;
export const isCorner = (char: string) => [
    Glyphs.SINGLE_TOP_LEFT, Glyphs.SINGLE_TOP_RIGHT, Glyphs.SINGLE_BOTTOM_LEFT, Glyphs.SINGLE_BOTTOM_RIGHT,
    Glyphs.DOUBLE_TOP_LEFT, Glyphs.DOUBLE_TOP_RIGHT, Glyphs.DOUBLE_BOTTOM_LEFT, Glyphs.DOUBLE_BOTTOM_RIGHT,
].includes(char as Glyphs);

interface BoxOptions {
    vertical?: string;
    horizontal?: string;
    topLeft?: string;
    topRight?: string;
    bottomLeft?: string;
    bottomRight?: string;
    fill?: Char;
    fg?: ColorLike;
    bg?: ColorLike;
    title?: string;
}

const CHAR_W = 8;
const CHAR_H = 16;
const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`;
const FALLBACK_FONT = `${CHAR_H}px monospace`;
const REGION_DATA = Symbol('TextRegion.data');

const colorToCSS = (c: ColorLike): string =>
    typeof c === 'number' ? COLORS[c] : c as string;

const parseChar = (char: Char, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG): DefinedChar => (
    typeof char === 'string'
        ? [char, fg, bg]
        : [
            char[0],
            char[1] ?? fg,
            char[2] ?? bg,
        ]
);

export class TextDisplay {
    private chars: string[];
    private fgs: ColorLike[];
    private bgs: ColorLike[];
    private canvas: HTMLCanvasElement;
    private ctx: CanvasRenderingContext2D;
    private font = FALLBACK_FONT;
    private letterboxColor: string;
    private clipLeft: number = 0;
    private clipTop: number = 0;
    private clipRight: number;
    private clipBottom: number;
    private dirtySet = new Set<number>();

    constructor(
        public width = GAME_WIDTH,
        public height = GAME_HEIGHT,
        parent?: HTMLCanvasElement,
        letterboxColor: ColorLike = DEFAULT_BG,
    ) {
        this.letterboxColor = colorToCSS(letterboxColor);
        const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H);
        canvas.width = width * CHAR_W;
        canvas.height = height * CHAR_H;
        canvas.style.imageRendering = 'pixelated';
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d')!;
        this.ctx.textBaseline = 'top';

        const size = width * height;
        this.chars = new Array(size).fill(' ');
        this.fgs = new Array(size).fill(DEFAULT_FG);
        this.bgs = new Array(size).fill(DEFAULT_BG);

        window.addEventListener('resize', () => this.updateScale());
        this.updateScale();

        Promise.race([
            document.fonts.load(NATIVE_FONT).then(() => true).catch(() => false),
            new Promise<boolean>(resolve => setTimeout(() => resolve(false), 1000)),
        ]).then(loaded => {
            this.font = loaded ? NATIVE_FONT : FALLBACK_FONT;
            this.redraw();
        });

        this.clipRight = width;
        this.clipBottom = height;
    }

    private updateScale() {
        const scale = Math.max(1, Math.min(
            Math.floor(window.innerWidth / (this.width * CHAR_W)),
            Math.floor(window.innerHeight / (this.height * CHAR_H)),
        ));
        this.canvas.style.width = `${this.width * CHAR_W * scale}px`;
        this.canvas.style.height = `${this.height * CHAR_H * scale}px`;
        document.body.style.background = this.letterboxColor;
    }

    private redrawDirty() {
        for (const i of this.dirtySet) {
            const x = i % this.width;
            const y = Math.floor(i / this.width);
            this.drawCell(x, y);
        }
    }

    private redraw() {
        for (let y = 0; y < this.height; y++) {
            for (let x = 0; x < this.width; x++) {
                this.drawCell(x, y);
            }
        }
    }

    private dirtyCell(x: number, y: number) {
        this.dirtySet.add(y * this.width + x);
    }

    private drawCell(x: number, y: number) {
        const i = y * this.width + x;
        const px = x * CHAR_W;
        const py = y * CHAR_H;

        this.ctx.fillStyle = colorToCSS(this.bgs[i]);
        this.ctx.fillRect(px, py, CHAR_W, CHAR_H);

        const char = this.chars[i];
        if (char !== ' ') {
            this.ctx.save();
            this.ctx.beginPath();
            this.ctx.rect(px, py, CHAR_W, CHAR_H);
            this.ctx.clip();
            this.ctx.font = this.font;
            this.ctx.fillStyle = colorToCSS(this.fgs[i]);
            this.ctx.fillText(char, px, py);
            this.ctx.restore();
        }
        this.dirtySet.delete(i);
    }

    private setCharRaw(x: number, y: number, char: string, fg: ColorLike, bg: ColorLike) {
        if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) return;
        if (!char || char === '\0') return;

        const i = (y | 0) * this.width + (x | 0);
        let dirty = false;
        if (this.chars[i] !== char) {
            this.chars[i] = char;
            dirty = true;
        }
        if (this.fgs[i] !== fg) {
            this.fgs[i] = fg;
            dirty = true;
        }
        if (this.bgs[i] !== bg) {
            this.bgs[i] = bg;
            dirty = true;
        }
        if (dirty) {
            this.dirtyCell(x | 0, y | 0);
        }
    }

    setChar(x: number, y: number, char: Char = Glyphs.FULL_BLOCK) {
        this.setCharRaw(x | 0, y | 0, ...parseChar(char));
    }

    getChar(x: number, y: number): DefinedChar {
        x = x | 0; y = y | 0;
        if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) {
            return [' ', DEFAULT_FG, DEFAULT_BG];
        }
        return [this.chars[y * this.width + x], this.fgs[y * this.width + x], this.bgs[y * this.width + x]];
    }

    setRegion(x: number, y: number, region: TextRegion) {
        x = x | 0;
        y = y | 0;
        const { chars, fgs, bgs } = region[REGION_DATA];
        const rw = region.width;
        const x0 = Math.max(this.clipLeft, x);
        const y0 = Math.max(this.clipTop, y);
        const x1 = Math.min(this.clipRight, x + rw);
        const y1 = Math.min(this.clipBottom, y + region.height);
        const copyW = x1 - x0;
        if (copyW <= 0) return;

        const regColOffset = x0 - x;
        for (let row = y0; row < y1; row++) {
            const regRow = row - y;
            const regBase = regRow * rw + regColOffset;
            const dispBase = row * this.width + x0;
            for (let i = 0; i < copyW; i++) {
                const ch = chars[regBase + i];
                if (ch === '\0') continue;
                let dirty = false;
                if (ch !== this.chars[dispBase + i]) {
                    dirty = true;
                    this.chars[dispBase + i] = ch;
                }
                if (this.fgs[dispBase + i] !== fgs[regBase + i]) {
                    this.fgs[dispBase + i] = fgs[regBase + i];
                    dirty = true;
                }
                if (this.bgs[dispBase + i] !== bgs[regBase + i]) {
                    this.bgs[dispBase + i] = bgs[regBase + i];
                    dirty = true;
                }
                if (dirty) {
                    this.dirtyCell(x0 + i, row);
                }
            }
        }
    }

    getRegion(x: number, y: number, w: number, h: number): TextRegion {
        x = x | 0; y = y | 0; w = w | 0; h = h | 0;
        const data: DefinedChar[][] = Array(h);

        for (let row = 0; row < h; row++) {
            const dispRow = y + row;
            const charsRow: DefinedChar[] = Array(w);
            for (let col = 0; col < w; col++) {
                const dispCol = x + col;
                if (dispCol < this.clipLeft || dispRow < this.clipTop || dispCol >= this.clipRight || dispRow >= this.clipBottom) {
                    charsRow[col] = ['\0', DEFAULT_FG, DEFAULT_BG];
                } else {
                    const i = dispRow * this.width + dispCol;
                    charsRow[col] = [this.chars[i], this.fgs[i], this.bgs[i]]
                }
            }
            data[row] = charsRow;
        }

        return new TextRegion(data);
    }

    drawString(text: unknown, x: number, y: number, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG) {
        x = x | 0; y = y | 0;
        const lines = String(text).split('\n');
        for (let row = 0; row < lines.length; row++) {
            const line = lines[row];
            const ry = y + row;
            if (ry < this.clipTop || ry >= this.clipBottom) continue;
            for (let col = 0; col < line.length; col++) {
                this.setCharRaw(x + col, ry, line[col], fg, bg);
            }
        }
    }

    drawVLine(x: number, y1: number, y2: number, char: Char = Glyphs.SINGLE_VERTICAL) {
        x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
        if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }

        y1 = Math.max(this.clipTop, y1);
        y2 = Math.min(this.clipBottom - 1, y2);
        if (x < this.clipLeft || x >= this.clipRight) return;

        const ch = parseChar(char);
        for (let y = y1; y <= y2; y++) {
            this.setCharRaw(x, y, ...ch);
        }
    }

    drawHLine(x1: number, x2: number, y: number, char: Char = Glyphs.SINGLE_HORIZONTAL) {
        x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
        if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
        x1 = Math.max(this.clipLeft, x1);
        x2 = Math.min(this.clipRight - 1, x2);
        if (y < this.clipTop || y >= this.clipBottom) return;

        const ch = parseChar(char);
        for (let x = x1; x <= x2; x++) {
            this.setCharRaw(x, y, ...ch);
        }
    }

    drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) {
        x = x | 0; y = y | 0;
        const {
            vertical = Glyphs.SINGLE_VERTICAL,
            horizontal = Glyphs.SINGLE_HORIZONTAL,
            topLeft = Glyphs.SINGLE_TOP_LEFT,
            topRight = Glyphs.SINGLE_TOP_RIGHT,
            bottomLeft = Glyphs.SINGLE_BOTTOM_LEFT,
            bottomRight = Glyphs.SINGLE_BOTTOM_RIGHT,
            fg = DEFAULT_FG,
            bg = DEFAULT_BG,
            fill,
            title,
        } = options;

        this.setCharRaw(x, y, topLeft, fg, bg);
        this.setCharRaw(x + width + 1, y, topRight, fg, bg);
        this.setCharRaw(x, y + height + 1, bottomLeft, fg, bg);
        this.setCharRaw(x + width + 1, y + height + 1, bottomRight, fg, bg);

        if (fill) {
            this.fillBox(x + 1, y + 1, width, height, fill);
        }

        this.drawHLine(x + 1, x + width, y, [horizontal, fg, bg]);
        this.drawHLine(x + 1, x + width, y + height + 1, [horizontal, fg, bg]);

        this.drawVLine(x, y + 1, y + height, [vertical, fg, bg]);
        this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]);

        if (title) {
            this.drawString(title, x + 1, y, fg, bg);
        }
    }

    drawStringInBox(text: unknown, x: number, y: number, options: BoxOptions = {}) {
        x = x | 0; y = y | 0;
        const {
            fg = DEFAULT_FG,
            bg = DEFAULT_BG,
        } = options;
        const lines = String(text).split('\n');
        const width = lines.reduce((m, line) => Math.max(m, line.length), 0);
        const height = lines.length;

        this.drawBox(x, y, width, height, { ...options, fill: ' ' });
        this.drawString(text, x + 1, y + 1, fg, bg);
    }

    fillBox(x: number, y: number, width: number, height: number, char: Char = Glyphs.FULL_BLOCK) {
        x = x | 0; y = y | 0;
        for (let i = y; i < y + height; i++) {
            this.drawHLine(x, x + width - 1, i, char);
        }
    }

    drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = Glyphs.FULL_BLOCK) {
        const [ch, fg, bg] = parseChar(char);
        const options: BresenhamLineOptions = {
            minX: this.clipLeft,
            minY: this.clipTop,
            maxX: this.clipRight - 1,
            maxY: this.clipBottom - 1,
            directions: 8,
        }
        for (const { x, y } of bresenhamLineGen(fromX, fromY, toX, toY, options)) {
            this.setCharRaw(x, y, ch, fg, bg);
        }
    }

    #circle(cx: number, cy: number, radius: number, char: Char, fill: boolean) {
        const [ch, fg, bg] = parseChar(char);
        const options: BresenhamCircleOptions = {
            minX: this.clipLeft,
            minY: this.clipTop,
            maxX: this.clipRight - 1,
            maxY: this.clipBottom - 1,
            fill,
        }
        for (const { x, y } of bresenhamCircleGen(cx, cy, radius, options)) {
            this.setCharRaw(x, y, ch, fg, bg);
        }
    }

    drawCircle(cx: number, cy: number, radius: number, char: Char = Glyphs.FULL_BLOCK) {
        this.#circle(cx, cy, radius, char, false);
    }

    fillCircle(cx: number, cy: number, radius: number, char: Char = Glyphs.FULL_BLOCK) {
        this.#circle(cx, cy, radius, char, true);
    }

    getClipRect(): Rect {
        return {
            x: this.clipLeft,
            y: this.clipTop,
            width: this.clipRight - this.clipLeft,
            height: this.clipBottom - this.clipTop,
        };
    }

    setClipRect(rect: Rect) {
        this.clipLeft = Math.max(0, rect.x);
        this.clipTop = Math.max(0, rect.y);
        this.clipRight = Math.min(this.width, rect.x + rect.width);
        this.clipBottom = Math.min(this.height, rect.y + rect.height);
    }

    update() {
        this.redrawDirty();
    }

    getMousePosition(): Point {
        const pos = Input.getMousePosition();
        return {
            x: Math.floor(pos.x / CHAR_W),
            y: Math.floor(pos.y / CHAR_H),
        };
    }
}

function expandColors(chars: string[] | Char[][], colors: ColorLike | ColorLike[] | ColorLike[][]): ColorLike[][] {
    if (!Array.isArray(colors)) {
        return chars.map(row => Array.from(row, () => colors));
    }
    if (colors.length === 0) {
        return [];
    }
    if (Array.isArray(colors[0])) {
        return colors as ColorLike[][];
    }
    const oldColors = colors as ColorLike[];
    const newColors: ColorLike[][] = [];
    let i = 0;
    for (const row of chars) {
        newColors.push(Array.from(row, () => oldColors[i++]));
    }
    return newColors;
}

export class TextRegion {
    readonly #chars: string[];
    readonly #fgs: ColorLike[];
    readonly #bgs: ColorLike[];
    readonly width: number;
    readonly height: number;

    constructor(chars: Char[][], fg?: ColorLike, bg?: ColorLike);
    constructor(chars: string, fg?: ColorLike | ColorLike[], bg?: ColorLike | ColorLike[]);
    constructor(chars: string[], fg?: ColorLike | ColorLike[][], bg?: ColorLike | ColorLike[][]);

    constructor(
        chars: Char[][] | string | string[],
        fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG,
        bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG,
    ) {
        if (typeof chars === 'string') {
            chars = chars.split('\n');
        }
        this.width = chars.reduce((m, r) => Math.max(m, r.length), 0);
        this.height = chars.length;
        const defaultFg = Array.isArray(fg) ? DEFAULT_FG : fg;
        const defaultBg = Array.isArray(bg) ? DEFAULT_BG : bg;

        fg = expandColors(chars, fg ?? defaultFg);
        bg = expandColors(chars, bg ?? defaultBg);

        const s = this.width * this.height;
        this.#chars = Array(s);
        this.#fgs = Array(s);
        this.#bgs = Array(s);

        for (let y = 0; y < this.height; y++) {
            for (let x = 0; x < this.width; x++) {
                const i = y * this.width + x;
                const char = chars[y]?.[x] ?? '\0';
                const charFG = fg[y]?.[x] ?? defaultFg;
                const charBG = bg[y]?.[x] ?? defaultBg;

                const ch = parseChar(char, charFG, charBG);
                this.#chars[i] = ch[0];
                this.#fgs[i] = ch[1];
                this.#bgs[i] = ch[2];
            }
        }
    }

    get(x: number, y: number): DefinedChar {
        const i = y * this.width + x;
        return [this.#chars[i], this.#fgs[i], this.#bgs[i]];
    }

    set(x: number, y: number, char: Char) {
        const ch = parseChar(char);
        this.#chars[y * this.width + x] = ch[0];
        this.#fgs[y * this.width + x] = ch[1];
        this.#bgs[y * this.width + x] = ch[2];
    }

    get [REGION_DATA]() {
        return {
            chars: this.#chars,
            fgs: this.#fgs,
            bgs: this.#bgs,
        };
    }
}
