// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
//
// SPDX-License-Identifier: MIT

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import {
	type ExtensionAPI,
	type ExtensionCommandContext,
	type ExtensionContext,
	getAgentDir,
	type SessionEntry,
	SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { pickModel } from "./model-utils.js";

/**
 * Model override sources for handoff extraction calls. Use provider/modelId
 * values (e.g. "anthropic/claude-haiku-4-5"). Precedence is:
 * --handoff-extraction-model, PI_HANDOFF_EXTRACTION_MODEL, user settings
 * handoffExtractionModel, session model.
 */
const HANDOFF_EXTRACTION_MODEL_FLAG = "handoff-extraction-model";
const HANDOFF_EXTRACTION_MODEL_ENV = "PI_HANDOFF_EXTRACTION_MODEL";
const HANDOFF_EXTRACTION_MODEL_SETTING = "handoffExtractionModel";

type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];

interface JsonObject {
	[key: string]: JsonValue;
}

export function registerHandoffExtractionModelFlag(pi: ExtensionAPI): void {
	pi.registerFlag(HANDOFF_EXTRACTION_MODEL_FLAG, {
		description: "Model for handoff extraction as provider/modelId",
		type: "string",
	});
}

export function registerHandoffExtractionModelCommand(pi: ExtensionAPI): void {
	pi.registerCommand("handoff:set-extraction-model", {
		description: "Choose the model used to extract handoff context",
		handler: async (args: string, ctx: ExtensionCommandContext) => {
			if (!ctx.hasUI || ctx.mode !== "tui") {
				ctx.ui.notify("/handoff:set-extraction-model requires interactive mode", "error");
				return;
			}

			const currentModel = configuredExtractionModel(pi, ctx);
			const selectedModel = await pickModel(ctx, currentModel, nonEmptyString(args));
			if (!selectedModel) {
				ctx.ui.notify("Handoff extraction model unchanged", "info");
				return;
			}

			const modelSpec = `${selectedModel.provider}/${selectedModel.id}`;
			try {
				setUserSettingsHandoffExtractionModel(modelSpec);
			} catch (err) {
				const message = err instanceof Error ? err.message : String(err);
				ctx.ui.notify(`Failed to save handoff extraction model: ${message}`, "error");
				return;
			}

			process.env[HANDOFF_EXTRACTION_MODEL_ENV] = modelSpec;

			if (configuredFlagHandoffExtractionModel(pi)) {
				ctx.ui.notify(
					`Saved handoff extraction model: ${modelSpec}. The --${HANDOFF_EXTRACTION_MODEL_FLAG} flag still takes precedence for this run.`,
					"warning",
				);
				return;
			}

			ctx.ui.notify(`Handoff extraction model: ${modelSpec}`, "info");
		},
	});
}

function nonEmptyString(value: string | undefined): string | undefined {
	const trimmed = value?.trim();
	return trimmed ? trimmed : undefined;
}

function stringProperty(source: object, key: string): string | undefined {
	const descriptor = Object.getOwnPropertyDescriptor(source, key);
	if (!descriptor || !("value" in descriptor)) return undefined;
	return typeof descriptor.value === "string" ? nonEmptyString(descriptor.value) : undefined;
}

function userSettingsHandoffExtractionModel(cwd: string): string | undefined {
	const settingsManager = SettingsManager.create(cwd, getAgentDir());
	return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_EXTRACTION_MODEL_SETTING);
}

function configuredFlagHandoffExtractionModel(pi: ExtensionAPI): string | undefined {
	const flagValue = pi.getFlag(HANDOFF_EXTRACTION_MODEL_FLAG);
	const flagModel = typeof flagValue === "string" ? nonEmptyString(flagValue) : undefined;
	return flagModel;
}

function configuredHandoffExtractionModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined {
	const flagModel = configuredFlagHandoffExtractionModel(pi);
	return (
		flagModel ??
		nonEmptyString(process.env[HANDOFF_EXTRACTION_MODEL_ENV]) ??
		userSettingsHandoffExtractionModel(ctx.cwd)
	);
}

function parseJsonObject(content: string, source: string): JsonObject {
	const parsed: JsonValue = JSON.parse(content);
	if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
		throw new Error(`${source} must contain a JSON object`);
	}
	return parsed;
}

function readSettingsObject(settingsPath: string): JsonObject {
	if (!existsSync(settingsPath)) return {};
	return parseJsonObject(readFileSync(settingsPath, "utf-8"), settingsPath);
}

function setUserSettingsHandoffExtractionModel(modelSpec: string): void {
	const settingsPath = path.join(getAgentDir(), "settings.json");
	const settings = readSettingsObject(settingsPath);
	settings[HANDOFF_EXTRACTION_MODEL_SETTING] = modelSpec;

	mkdirSync(path.dirname(settingsPath), { recursive: true });
	writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
}

function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
	const slashIdx = spec.indexOf("/");
	if (slashIdx <= 0 || slashIdx === spec.length - 1) return undefined;
	return {
		provider: spec.slice(0, slashIdx),
		modelId: spec.slice(slashIdx + 1),
	};
}

export function configuredExtractionModel(
	pi: ExtensionAPI,
	ctx: {
		cwd: string;
		modelRegistry: ExtensionContext["modelRegistry"];
	},
): ExtensionContext["model"] {
	const modelSpec = configuredHandoffExtractionModel(pi, ctx);
	if (!modelSpec) return undefined;
	const parsed = parseModelSpec(modelSpec);
	if (!parsed) return undefined;
	return ctx.modelRegistry.find(parsed.provider, parsed.modelId);
}

/**
 * Build a candidate file set from two sources:
 *   1. Primary: actual tool calls (read, write, edit, create) in the session
 *   2. Secondary: file-like patterns in the conversation text (catches files
 *      that were discussed but never opened)
 */
export function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set<string> {
	const files = new Set<string>();
	const fileToolNames = new Set(["read", "write", "edit", "create"]);

	// Primary: files from actual tool calls
	for (const entry of entries) {
		if (entry.type !== "message") continue;
		const msg = entry.message;
		if (msg.role !== "assistant") continue;

		for (const block of msg.content) {
			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
			if (!fileToolNames.has(block.name)) continue;

			const args = block.arguments as Record<string, unknown>;
			const filePath =
				typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined;
			if (!filePath) continue;
			if (filePath.endsWith("/SKILL.md")) continue;

			files.add(filePath);
		}
	}

	// Secondary: file-like patterns from conversation text.
	// Trailing lookahead so the boundary isn't consumed — otherwise adjacent
	// files separated by a single space (e.g. "file1.txt file2.txt") get skipped.
	const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm;
	for (const match of conversationText.matchAll(filePattern)) {
		const candidate = match[1];
		if (candidate && !candidate.startsWith(".") && candidate.length > 2) {
			files.add(candidate);
		}
	}

	return files;
}

/**
 * Extract skill names that were actually loaded during the conversation.
 * Looks for read() tool calls targeting SKILL.md files and derives the
 * skill name from the parent directory (the convention for pi skills).
 */
export function extractLoadedSkills(entries: SessionEntry[]): string[] {
	const skills = new Set<string>();
	for (const entry of entries) {
		if (entry.type !== "message") continue;
		const msg = entry.message;
		if (msg.role !== "assistant") continue;

		for (const block of msg.content) {
			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;

			// read() calls where the path ends in SKILL.md
			if (block.name !== "read") continue;
			const args = block.arguments as Record<string, unknown>;
			const filePath = typeof args.path === "string" ? args.path : undefined;
			if (!filePath?.endsWith("/SKILL.md")) continue;

			// Skill name is the parent directory name:
			//   .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld
			const parent = path.basename(path.dirname(filePath));
			if (parent && parent !== "skills") {
				skills.add(parent);
			}
		}
	}
	return [...skills].sort();
}
