/**
 * Neko Cat Extension - a pixel-art cat living above the chat editor.
 *
 * The cat sits (blinking, flicking its tail) while pi is idle, paces slowly
 * while the model is thinking, and paces briskly while it is writing text or
 * calling tools. When fast mode (`/fast`) is on, the cat gets the zoomies and
 * sprints across the row instead of pacing. When ultra mode (`/ultra`) is on,
 * the moving cat streams a nyan-style rainbow wake; each trail column
 * remembers the body height the cat had as it passed over it, so the wake
 * ripples with the large cat's hops instead of moving as a rigid bar. The
 * wake follows the cat's path, so after a turnaround the old wake persists
 * and fades in place while a fresh one grows on the other side.
 *
 * Sprites are ported from the neko-cat pixel-art library (tortoiseshell cat,
 * mini 22x12 and large 34x22 variants) and rendered as half-blocks (one `▀`
 * cell = two vertical pixels). Uses 24-bit color when the terminal reports
 * truecolor; otherwise falls back to the xterm 256-color palette so hosts
 * like Apple Terminal.app (macOS < Tahoe) still show the right colors.
 *
 * Configuration is stored as neko.json in the agent config dir and changed at
 * runtime via:
 *   /neko              show current settings
 *   /neko mini|large   choose cat size
 *   /neko tortie|dilute choose fur color
 *   /neko headlow|headup  large cat: head 1px left and 1px lower (relaxed look)
 *   /neko on|off       show or hide the cat
 */

import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
	bgAnsi,
	blank,
	cleanHead,
	colorMode,
	composeLargeSit,
	type ExtensionAPI,
	type ExtensionContext,
	fgAnsi,
	type Grid,
	getAgentDir,
	grid,
	MINI_SIT,
	MINI_WALK,
	paste,
	type Rgb,
	renderSprite,
	type Skin,
	setPixels,
} from "@neko-mono/coding-agent";
import type { Component, TUI } from "@neko-mono/tui";

// ---------------------------------------------------------------- config

type Size = "mini" | "large";

interface NekoConfig {
	size: Size;
	skin: Skin;
	/** Large cat only: carry the head 1px left and 1px lower in every pose
	 * (default, matching upstream's settled silhouette; disable with headup). */
	headLow: boolean;
	enabled: boolean;
}

const CONFIG_PATH = join(getAgentDir(), "neko.json");
const DEFAULT_CONFIG: NekoConfig = { size: "mini", skin: "tortie", headLow: true, enabled: true };

function loadConfig(): NekoConfig {
	try {
		const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as Partial<NekoConfig>;
		return { ...DEFAULT_CONFIG, ...raw };
	} catch {
		return { ...DEFAULT_CONFIG };
	}
}

function saveConfig(config: NekoConfig): void {
	writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, "\t")}\n`);
}

// ---------------------------------------------------------------- sprites

const CAT_HEAD = cleanHead(
	grid(
		"....KK........KK....",
		"...KBBK......KOOK...",
		"...KBPBK....KOPOK...",
		"...KBBBBKKKKOOOOK...",
		"...KBbBBBBOOoOOOK...",
		"..KBBGGBBBOOOGGOOK..",
		"..KBBGWBBBOOOGWOOK..",
		"wwKBBBBBBNNOOOOOOKww",
		"..KBOBBBBBBOOOOOOK..",
		"wwKBBBBBBBOOOCOOKww.",
		"..KBBBBBBOOOOOOK....",
		"..........KKKKKK....",
	),
);
const CAT_TORSO = grid(
	".....KKKKKKKKKKKKKKK",
	"..KBBOOOBBBBBBBBBBBK",
	"..KBBOOOBBCBBBBOOBBK", // neck patch on the chest (clean-face variant)
	"..KBBBOBBBBBBBBBOBBK",
	"...KBBBBBBBBBBBBBBBK",
	"....KKKKKKKKKKKKKKKK",
);
const CAT_TAIL_UP = grid(".KK..", "KOOK.", "KOOK.", "KBBK.", "KBBK.", ".KBBK", ".KBBK");
const CAT_TAIL_SWAY = grid("..KK.", ".KOOK", ".KOOK", "KBBK.", "KBBK.", ".KBBK", ".KBBK");
const CAT_LEGS_STAND = grid(
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FFKBBK.",
	".....KKKK.FF.FFKKKK.",
);
const CAT_LEGS_REACH = grid(
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FFKBBK.",
	"....KBBK.FF...FFKBBK",
	"....KKKK.FF...FFKKKK",
);
const CAT_LEGS_CROSS = grid(
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FFKBBK.",
	"......KBBKFF.FKBBK..",
	"......KKKKFF.FKKKK..",
);
const CAT_LEGS_LIFT_F = grid(
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FF.KKKK",
	".....KKKK.FF.FF.....",
);
const CAT_LEGS_LIFT_B = grid(
	".....KBBK.FF.FFKBBK.",
	".....KBBK.FF.FFKBBK.",
	"....KKKK..FF.FFKBBK.",
	"..........FF.FFKKKK.",
);
const CAT_LEGS_TUCK = grid(".....KBBK.FF.FFKBBK.", ".....KKKK.FF.FFKKKK.");

interface CatPose {
	legs: Grid;
	tail: Grid;
	/** Drop body and head one pixel (legs stay planted), or lift with legsOff. */
	bob?: number;
	/** Vertical leg offset, for airborne hop frames. */
	legsOff?: number;
	headOff?: readonly [number, number];
}

function catCompose({ legs, tail, bob = 0, legsOff = 0, headOff = [0, 0] }: CatPose): string[] {
	const canvas = blank(34, 22);
	paste(canvas, tail, 2, 5 + bob);
	paste(canvas, CAT_TORSO, 2, 11 + bob);
	paste(canvas, legs, 2, 17 + legsOff);
	paste(canvas, CAT_HEAD, 12 + headOff[0], 1 + headOff[1] + bob);
	return canvas.map((row) => row.join(""));
}

// ---------------------------------------------------------------- variants

interface IdleFrame {
	grid: Grid;
	ms: number;
}

// Nyan wake (ultra mode): rainbow trail along the moving cat's path. The
// table is indexed by sample age (0 = freshest) and pre-fades toward the tail
// end so no per-render color math is needed. Length is a fraction of the
// screen width; the table is rebuilt (and cached) when that changes.
const RAINBOW: readonly Rgb[] = [
	[255, 64, 64], // red
	[255, 158, 32], // orange
	[255, 232, 32], // yellow
	[72, 214, 72], // green
	[64, 150, 255], // blue
	[168, 96, 255], // violet
];

const TRAIL_FRACTION = 0.6;

let trailCache: { len: number; colors: readonly (readonly Rgb[])[] } | undefined;

function trailColors(len: number): readonly (readonly Rgb[])[] {
	if (trailCache?.len !== len) {
		const full = Math.round(len * 0.45); // columns at full brightness before the taper starts
		const colors = Array.from({ length: len }, (_, d) => {
			const fade = d < full ? 1 : 1 - (0.7 * (d - full + 1)) / (len - full);
			return RAINBOW.map((c): Rgb => [Math.round(c[0] * fade), Math.round(c[1] * fade), Math.round(c[2] * fade)]);
		});
		trailCache = { len, colors };
	}
	return trailCache.colors;
}

interface Variant {
	cols: number;
	think: Grid[];
	out: Grid[];
	/** Per-out-frame body y offset in pixels (hop height), for the ultra trail. */
	outBob: readonly number[];
	/** Pixel row of the ultra trail's top rainbow stripe when the body is level. */
	trailTop: number;
	thinkMs: number;
	outMs: number;
	idle: IdleFrame[];
}

// Idle: still -> tail flick -> still -> blink.
const MINI_VARIANT: Variant = {
	cols: 22,
	think: MINI_WALK,
	out: MINI_WALK,
	outBob: [0, 0, 0, 0],
	trailTop: 5,
	thinkMs: 210,
	outMs: 140,
	idle: [
		{ grid: MINI_SIT, ms: 1400 },
		{
			grid: setPixels(MINI_SIT, [
				[11, 7, "K"],
				[11, 9, "O"],
			]),
			ms: 180,
		},
		{ grid: MINI_SIT, ms: 900 },
		{
			grid: setPixels(MINI_SIT, [
				[4, 12, "K"],
				[4, 17, "K"],
			]),
			ms: 140,
		},
	],
};

function buildLargeVariant(headOff: readonly [number, number]): Variant {
	const [dx, dy] = headOff;

	// Bouncy kitten hop: crouch, spring airborne, land. The bob values also
	// feed the ultra trail so the wake follows the hop.
	const pranceBob = [1, -1, -1, 0] as const;
	const prance: Grid[] = [
		catCompose({ legs: CAT_LEGS_STAND, tail: CAT_TAIL_SWAY, bob: pranceBob[0], headOff }),
		catCompose({ legs: CAT_LEGS_TUCK, tail: CAT_TAIL_UP, bob: pranceBob[1], legsOff: -1, headOff }),
		catCompose({ legs: CAT_LEGS_REACH, tail: CAT_TAIL_UP, bob: pranceBob[2], legsOff: -1, headOff }),
		catCompose({ legs: CAT_LEGS_REACH, tail: CAT_TAIL_SWAY, bob: pranceBob[3], headOff }),
	];

	// Slow stalk: head low and forward (on top of any base offset), deliberate steps.
	const prowlOff: readonly [number, number] = [dx + 1, dy + 1];
	const prowl: Grid[] = [
		catCompose({ legs: CAT_LEGS_REACH, tail: CAT_TAIL_UP, headOff: prowlOff }),
		catCompose({ legs: CAT_LEGS_LIFT_B, tail: CAT_TAIL_UP, headOff: prowlOff }),
		catCompose({ legs: CAT_LEGS_CROSS, tail: CAT_TAIL_SWAY, headOff: prowlOff }),
		catCompose({ legs: CAT_LEGS_LIFT_F, tail: CAT_TAIL_SWAY, headOff: prowlOff }),
	];

	// Sitting: body plus the sit head part at its post-shift anchor (0, 0), plus offset.
	const sit = composeLargeSit(headOff);

	return {
		cols: 34,
		think: prowl,
		out: prance,
		outBob: pranceBob,
		trailTop: 11,
		thinkMs: 210,
		outMs: 130,
		idle: [
			{ grid: sit, ms: 1400 },
			{
				// Tail flick: body pixels, unaffected by the head offset.
				grid: setPixels(sit, [
					[20, 8, "K"],
					[20, 11, "O"],
				]),
				ms: 180,
			},
			{ grid: sit, ms: 900 },
			{
				// Blink: eye pixels move with the head.
				grid: setPixels(sit, [
					[5 + dy, 17 + dx, "B"],
					[6 + dy, 17 + dx, "K"],
					[5 + dy, 18 + dx, "B"],
					[6 + dy, 18 + dx, "K"],
					[5 + dy, 25 + dx, "O"],
					[6 + dy, 25 + dx, "K"],
					[5 + dy, 26 + dx, "O"],
					[6 + dy, 26 + dx, "K"],
				]),
				ms: 140,
			},
		],
	};
}

const LARGE_VARIANT = buildLargeVariant([0, 0]);
const LARGE_HEADLOW_VARIANT = buildLargeVariant([-1, 1]);

function variantFor(config: NekoConfig): Variant {
	if (config.size === "mini") return MINI_VARIANT;
	return config.headLow ? LARGE_HEADLOW_VARIANT : LARGE_VARIANT;
}

// ---------------------------------------------------------------- rendering

const mirrorCache = new Map<Grid, Grid>();

function mirrored(g: Grid): Grid {
	let m = mirrorCache.get(g);
	if (!m) {
		m = g.map((row) => [...row].reverse().join(""));
		mirrorCache.set(g, m);
	}
	return m;
}

// ---------------------------------------------------------------- widget

type Mode = "idle" | "think" | "out";

// Keep the idle animation (blinks, tail flicks) running this long after the
// agent settles, then freeze on the still frame and stop the timer. Every
// animation tick makes the TUI re-render and diff the whole transcript, so a
// forever-idling cat would make long sessions progressively laggier.
const IDLE_ANIMATE_MS = 60_000;

// Zoomies (fast mode): sprint with longer strides and quicker frames.
const ZOOMIES_STRIDE = 3;
const ZOOMIES_SPEEDUP = 3;
const ZOOMIES_MIN_FRAME_MS = 45;

class NekoWidget implements Component {
	private tui: TUI;
	private getConfig: () => NekoConfig;
	private isFast: () => boolean;
	private isUltra: () => boolean;
	private mode: Mode = "idle";
	private x = 2;
	private dir = 1;
	private tick = 0;
	private idleIdx = 0;
	private idleSince = Date.now();
	private lastWidth = 80;
	private timer: ReturnType<typeof setTimeout> | undefined;
	private lastRender:
		| { lines: string[]; grid: Grid; x: number; skin: Skin; width: number; trailKey: string }
		| undefined;
	/** Recent path of the sprite's trailing edge (column + hop height), oldest
	 * first, for the ultra wake. Keeping the actual path lets the wake persist
	 * and fade in place when the cat turns around. */
	private wake: { col: number; bob: number }[] = [];

	constructor(tui: TUI, getConfig: () => NekoConfig, isFast: () => boolean, isUltra: () => boolean) {
		this.tui = tui;
		this.getConfig = getConfig;
		this.isFast = isFast;
		this.isUltra = isUltra;
		this.timer = setTimeout(() => this.step(), 600);
	}

	setMode(mode: Mode): void {
		// "idle" always falls through: agent_settled can fire while already idle
		// (e.g. a run that errors before agent_start reaches the widget), and a
		// napping cat must still refresh its nap deadline and restart its timer.
		if (this.mode === mode && mode !== "idle") return;
		this.mode = mode;
		if (mode === "idle") {
			this.idleIdx = 0;
			this.idleSince = Date.now();
		}
		if (!this.timer) this.timer = setTimeout(() => this.step(), 16); // wake from nap
		this.tui.requestRender();
	}

	private step(): void {
		const variant = variantFor(this.getConfig());
		let ms: number;
		if (this.mode === "idle") {
			if (Date.now() - this.idleSince > IDLE_ANIMATE_MS) {
				// Nap: freeze on the still sit frame, no timer until the next mode change.
				this.idleIdx = 0;
				this.timer = undefined;
				this.tui.requestRender();
				return;
			}
			this.idleIdx = (this.idleIdx + 1) % variant.idle.length;
			ms = variant.idle[this.idleIdx].ms;
		} else {
			this.tick++;
			const zoomies = this.isFast();
			const maxX = Math.max(0, this.lastWidth - variant.cols);
			const fromX = this.x;
			const fromDir = this.dir; // wall bounces flip dir below; record with the travel direction
			this.x += this.dir * (zoomies ? ZOOMIES_STRIDE : 1);
			if (this.x <= 0) {
				this.x = 0;
				this.dir = 1;
			} else if (this.x >= maxX) {
				this.x = maxX;
				this.dir = -1;
			}
			this.recordTrail(fromX, fromDir, variant);
			ms = this.mode === "think" ? variant.thinkMs : variant.outMs;
			if (zoomies) ms = Math.max(ZOOMIES_MIN_FRAME_MS, Math.round(ms / ZOOMIES_SPEEDUP));
		}
		this.tui.requestRender();
		this.timer = setTimeout(() => this.step(), ms);
	}

	/** Append the columns the trailing edge covered this step (with the current
	 * hop height) to the wake path, then trim it to the trail length. */
	private recordTrail(fromX: number, dir: number, variant: Variant): void {
		const bob = this.mode === "out" ? variant.outBob[this.tick % 4] : 0;
		const anchor = dir > 0 ? 0 : variant.cols - 1;
		const a = Math.min(fromX, this.x) + anchor;
		const b = Math.max(fromX, this.x) + anchor;
		// Push in traversal order so the sample nearest the cat is newest.
		if (dir > 0) for (let c = a; c <= b; c++) this.wake.push({ col: c, bob });
		else for (let c = b; c >= a; c--) this.wake.push({ col: c, bob });
		const len = this.trailLen();
		if (this.wake.length > len) this.wake.splice(0, this.wake.length - len);
	}

	private trailLen(): number {
		return Math.max(4, Math.round(this.lastWidth * TRAIL_FRACTION));
	}

	/** Compose sprite lines with the rainbow wake along the cat's path. */
	private withTrail(sprite: string[], variant: Variant, width: number): string[] {
		const mode = colorMode();
		// Newest sample per column wins, so where the wake crosses itself after a
		// turnaround the fresher (brighter) pass is drawn.
		const byCol = new Map<number, { age: number; bob: number }>();
		for (let i = 0; i < this.wake.length; i++) {
			const sample = this.wake[i];
			byCol.set(sample.col, { age: this.wake.length - 1 - i, bob: sample.bob });
		}
		const table = trailColors(this.trailLen());
		const cell = (c: number, r: number): string => {
			const sample = byCol.get(c);
			if (!sample) return " ";
			// Age can exceed the table right after the terminal shrinks.
			const colors = table[sample.age];
			if (!colors) return " ";
			const ti = 2 * r - (variant.trailTop + sample.bob);
			const top = ti >= 0 && ti < RAINBOW.length ? colors[ti] : undefined;
			const bot = ti + 1 >= 0 && ti + 1 < RAINBOW.length ? colors[ti + 1] : undefined;
			if (top && bot) return `${fgAnsi(top, mode)}${bgAnsi(bot, mode)}▀\x1b[0m`;
			if (top) return `${fgAnsi(top, mode)}▀\x1b[0m`;
			if (bot) return `${fgAnsi(bot, mode)}▄\x1b[0m`;
			return " ";
		};
		const lines: string[] = [];
		for (let r = 0; r < sprite.length; r++) {
			let left = "";
			for (let c = 0; c < this.x; c++) left += cell(c, r);
			let right = "";
			for (let c = this.x + variant.cols; c < width; c++) right += cell(c, r);
			lines.push(left + sprite[r] + right);
		}
		return lines;
	}

	render(width: number): string[] {
		this.lastWidth = width;
		const config = this.getConfig();
		const variant = variantFor(config);
		if (width < variant.cols) return [];
		this.x = Math.min(this.x, width - variant.cols);
		let g =
			this.mode === "idle"
				? variant.idle[this.idleIdx % variant.idle.length].grid
				: (this.mode === "think" ? variant.think : variant.out)[this.tick % 4];
		if (this.dir < 0) g = mirrored(g);
		// Return the identical array instance when nothing changed, so the TUI's
		// reference-equality render caches stay valid for unrelated re-renders.
		// Trail content only changes on animation steps or when ultra toggles;
		// width is in the key because the wake pads out to the terminal edge.
		const trailKey = this.mode !== "idle" && this.isUltra() ? `ultra:${this.tick}` : "";
		const last = this.lastRender;
		if (
			last &&
			last.grid === g &&
			last.x === this.x &&
			last.skin === config.skin &&
			last.width === width &&
			last.trailKey === trailKey
		)
			return last.lines;
		const sprite = renderSprite(g, config.skin);
		const lines =
			trailKey === "" ? sprite.map((line) => " ".repeat(this.x) + line) : this.withTrail(sprite, variant, width);
		this.lastRender = { lines, grid: g, x: this.x, skin: config.skin, width, trailKey };
		return lines;
	}

	invalidate(): void {}

	dispose(): void {
		if (this.timer) clearTimeout(this.timer);
		this.timer = undefined;
	}
}

// ---------------------------------------------------------------- extension

const NEKO_OPTIONS = ["mini", "large", "tortie", "dilute", "headlow", "headup", "on", "off"] as const;

export default function nekoCatExtension(pi: ExtensionAPI) {
	const config = loadConfig();
	let widget: NekoWidget | undefined;

	const applyWidget = (ctx: ExtensionContext) => {
		if (ctx.mode !== "tui") return;
		if (config.enabled) {
			ctx.ui.setWidget("neko-cat", (tui) => {
				// Swallow stale-runtime errors from timer ticks around reloads.
				const isFast = () => {
					try {
						return pi.getFastMode();
					} catch {
						return false;
					}
				};
				const isUltra = () => {
					try {
						return pi.getUltraMode();
					} catch {
						return false;
					}
				};
				widget = new NekoWidget(tui, () => config, isFast, isUltra);
				return widget;
			});
		} else {
			ctx.ui.setWidget("neko-cat", undefined);
			widget = undefined;
		}
	};

	pi.on("session_start", (_event, ctx) => applyWidget(ctx));
	// Prompt processing (before the first token, and between turns while waiting
	// for the next response) shows the thinking gait; output deltas switch to "out".
	pi.on("agent_start", () => widget?.setMode("think"));
	pi.on("turn_start", () => widget?.setMode("think"));
	pi.on("agent_settled", () => widget?.setMode("idle"));
	pi.on("message_update", (event) => {
		const type = event.assistantMessageEvent.type;
		if (type === "thinking_start" || type === "thinking_delta") widget?.setMode("think");
		else if (type === "text_start" || type === "text_delta" || type.startsWith("toolcall_")) widget?.setMode("out");
	});

	pi.registerCommand("neko", {
		description: "Neko cat settings: mini|large, tortie|dilute, headlow|headup, on|off",
		getArgumentCompletions: (prefix) => {
			const items = NEKO_OPTIONS.filter((option) => option.startsWith(prefix)).map((option) => ({
				value: option,
				label: option,
			}));
			return items.length > 0 ? items : null;
		},
		handler: async (args, ctx) => {
			const arg = args?.trim() ?? "";
			if (arg === "") {
				const head = config.headLow ? "headlow" : "headup";
				ctx.ui.notify(`neko: ${config.size}, ${config.skin}, ${head}, ${config.enabled ? "on" : "off"}`, "info");
				return;
			}
			if (arg === "mini" || arg === "large") config.size = arg;
			else if (arg === "tortie" || arg === "dilute") config.skin = arg;
			else if (arg === "headlow") config.headLow = true;
			else if (arg === "headup") config.headLow = false;
			else if (arg === "on") config.enabled = true;
			else if (arg === "off") config.enabled = false;
			else {
				ctx.ui.notify(`Unknown option: ${arg} (use ${NEKO_OPTIONS.join(", ")})`, "error");
				return;
			}
			saveConfig(config);
			applyWidget(ctx);
		},
	});
}
