/**
 * Extension Registry — manages manifest reading, registry persistence, and enable/disable state.
 *
 * Extensions without manifests always load (backwards compatible).
 * A fresh install has an empty registry — all extensions enabled by default.
 * The only way an extension stops loading is an explicit `sf extensions disable <id>`.
 *
 * Purpose: provide a single source of truth for which extensions are active so that
 * the loader can decide what to load and the CLI can show the user what is installed.
 *
 * Consumer: extension-discovery.ts (reads manifests), loader.ts (decides what to load),
 * and commands-handlers.ts (implements `sf extensions list/enable/disable`).
 */

import {
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	renameSync,
	writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { appRoot } from "./app-paths.js";

// ─── Types ──────────────────────────────────────────────────────────────────

/**
 * Describes the static metadata shipped with an extension.
 *
 * Purpose: let the registry and loader validate an extension before loading it
 * and present human-readable information in CLI listings.
 *
 * Consumer: readManifest(), discoverAllManifests(), and the `sf extensions list` command.
 */
export interface ExtensionManifest {
	id: string;
	name: string;
	version: string;
	description: string;
	tier: "core" | "bundled" | "community";
	requires: { platform: string };
	provides?: {
		tools?: string[];
		commands?: string[];
		hooks?: string[];
		shortcuts?: string[];
	};
	dependencies?: {
		extensions?: string[];
		runtime?: string[];
	};
}

/**
 * A single entry in the on-disk registry file.
 *
 * Purpose: persist whether an extension is enabled, why it was disabled, and
 * where it came from so that decisions survive process restarts.
 *
 * Consumer: loadRegistry(), saveRegistry(), and the enable/disable mutations.
 */
export interface ExtensionRegistryEntry {
	id: string;
	enabled: boolean;
	source: "bundled" | "user" | "project";
	disabledAt?: string;
	disabledReason?: string;
}

/**
 * The top-level shape of the persisted registry file.
 *
 * Purpose: version the JSON schema so future migrations can detect old formats.
 *
 * Consumer: loadRegistry() and saveRegistry().
 */
export interface ExtensionRegistry {
	version: 1;
	entries: Record<string, ExtensionRegistryEntry>;
}

// ─── Validation ─────────────────────────────────────────────────────────────

function isRegistry(data: unknown): data is ExtensionRegistry {
	if (typeof data !== "object" || data === null) return false;
	const obj = data as Record<string, unknown>;
	return (
		obj.version === 1 && typeof obj.entries === "object" && obj.entries !== null
	);
}

function isManifest(data: unknown): data is ExtensionManifest {
	if (typeof data !== "object" || data === null) return false;
	const obj = data as Record<string, unknown>;
	return (
		typeof obj.id === "string" &&
		typeof obj.name === "string" &&
		typeof obj.version === "string" &&
		typeof obj.tier === "string"
	);
}

// ─── Registry Path ──────────────────────────────────────────────────────────

/**
 * Returns the absolute path to the persisted registry JSON file.
 *
 * Purpose: centralise the registry location so every I/O operation targets the
 * same file and the path can be overridden in tests.
 *
 * Consumer: loadRegistry(), saveRegistry(), and test fixtures that need to
 * point at a temporary registry.
 */
export function getRegistryPath(): string {
	return join(appRoot, "extensions", "registry.json");
}

// ─── Registry I/O ───────────────────────────────────────────────────────────

function defaultRegistry(): ExtensionRegistry {
	return { version: 1, entries: {} };
}

/**
 * Reads the registry from disk, returning a default empty registry if the file
 * is missing, malformed, or unreadable.
 *
 * Purpose: guarantee that every caller receives a valid ExtensionRegistry object
 * without having to handle I/O edge cases themselves.
 *
 * Consumer: ensureRegistryEntries(), the extension loader, and CLI commands that
 * need to inspect or mutate extension state.
 */
export function loadRegistry(): ExtensionRegistry {
	const filePath = getRegistryPath();
	try {
		if (!existsSync(filePath)) return defaultRegistry();
		const raw = readFileSync(filePath, "utf-8");
		const parsed = JSON.parse(raw);
		return isRegistry(parsed) ? parsed : defaultRegistry();
	} catch {
		return defaultRegistry();
	}
}

/**
 * Atomically writes the registry to disk using a temp-file + rename pattern.
 *
 * Purpose: prevent corrupt or partial registry files if the process crashes
 * mid-write, and silently swallow non-fatal persistence errors so the CLI
 * remains usable even when the filesystem is read-only.
 *
 * Consumer: enableExtension(), disableExtension(), and ensureRegistryEntries().
 */
export function saveRegistry(registry: ExtensionRegistry): void {
	const filePath = getRegistryPath();
	try {
		mkdirSync(dirname(filePath), { recursive: true });
		const tmp = filePath + ".tmp";
		writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8");
		renameSync(tmp, filePath);
	} catch {
		// Non-fatal — don't let persistence failures break operation
	}
}

// ─── Query ──────────────────────────────────────────────────────────────────

/**
 * Returns true if the extension is enabled (missing entries default to enabled).
 *
 * Purpose: let the loader decide whether to activate an extension without
 * requiring every caller to know the "missing means enabled" default.
 *
 * Consumer: the extension loader and `sf extensions list` when rendering status.
 */
export function isExtensionEnabled(
	registry: ExtensionRegistry,
	id: string,
): boolean {
	const entry = registry.entries[id];
	if (!entry) return true;
	return entry.enabled;
}

// ─── Mutations ──────────────────────────────────────────────────────────────

/**
 * Marks an extension as enabled, clearing any previous disable metadata.
 *
 * Purpose: provide the atomic state transition used by `sf extensions enable`
 * and by the auto-discovery flow when a new extension is first seen.
 *
 * Consumer: `sf extensions enable` command handler and ensureRegistryEntries().
 */
export function enableExtension(registry: ExtensionRegistry, id: string): void {
	const entry = registry.entries[id];
	if (entry) {
		entry.enabled = true;
		delete entry.disabledAt;
		delete entry.disabledReason;
	} else {
		registry.entries[id] = { id, enabled: true, source: "bundled" };
	}
}

/**
 * Disable an extension. Returns an error string if the extension is core (cannot disable),
 * or null on success.
 *
 * Purpose: protect core extensions from accidental disablement while allowing users
 * to turn off bundled or community extensions and recording why.
 *
 * Consumer: `sf extensions disable` command handler.
 */
export function disableExtension(
	registry: ExtensionRegistry,
	id: string,
	manifest: ExtensionManifest | null,
	reason?: string,
): string | null {
	if (manifest?.tier === "core") {
		return `Cannot disable "${id}" — it is a core extension.`;
	}
	const entry = registry.entries[id];
	if (entry) {
		entry.enabled = false;
		entry.disabledAt = new Date().toISOString();
		entry.disabledReason = reason;
	} else {
		registry.entries[id] = {
			id,
			enabled: false,
			source: "bundled",
			disabledAt: new Date().toISOString(),
			disabledReason: reason,
		};
	}
	return null;
}

// ─── Manifest Reading ───────────────────────────────────────────────────────

/**
 * Read extension-manifest.json from a directory. Returns null if missing or invalid.
 *
 * Purpose: isolate manifest parsing and validation so callers receive either a
 * fully typed ExtensionManifest or a clear null signal.
 *
 * Consumer: discoverAllManifests(), readManifestFromEntryPath(), and tests that
 * verify manifest schema evolution.
 */
export function readManifest(extensionDir: string): ExtensionManifest | null {
	const manifestPath = join(extensionDir, "extension-manifest.json");
	if (!existsSync(manifestPath)) return null;
	try {
		const raw = JSON.parse(readFileSync(manifestPath, "utf-8"));
		return isManifest(raw) ? raw : null;
	} catch {
		return null;
	}
}

/**
 * Given an entry path (e.g. `.../extensions/browser-tools/index.ts`),
 * resolve the parent directory and read its manifest.
 *
 * Purpose: bridge the gap between a discovered entry-point file and its
 * containing extension's metadata, used when the loader needs tier or
 * dependency information for a specific file.
 *
 * Consumer: extension loader when validating an entry point before require()ing it.
 */
export function readManifestFromEntryPath(
	entryPath: string,
): ExtensionManifest | null {
	const dir = dirname(entryPath);
	return readManifest(dir);
}

// ─── Discovery ──────────────────────────────────────────────────────────────

/**
 * Scan all subdirectories of extensionsDir for manifests. Returns a Map<id, manifest>.
 *
 * Purpose: produce a complete, de-duplicated inventory of installed extensions
 * so the registry can be reconciled against the filesystem.
 *
 * Consumer: ensureRegistryEntries() and the extension loader's bootstrap phase.
 */
export function discoverAllManifests(
	extensionsDir: string,
): Map<string, ExtensionManifest> {
	const manifests = new Map<string, ExtensionManifest>();
	if (!existsSync(extensionsDir)) return manifests;

	for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
		// Accept both real directories and directory symlinks. Dirent.isDirectory()
		// returns false for symlinks even when they point at a directory, so dev-workflow
		// symlinked extensions under ~/.sf/agent/extensions/ are invisible otherwise
		// (regression tested in symlink-extension-discovery.test.ts).
		if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
		const manifest = readManifest(join(extensionsDir, entry.name));
		if (manifest) {
			manifests.set(manifest.id, manifest);
		}
	}
	return manifests;
}

/**
 * Auto-populate registry entries for newly discovered extensions.
 * Extensions already in the registry are left untouched.
 *
 * Purpose: keep the registry in sync with the filesystem after installs or
 * updates without overwriting user preferences (e.g. disabled state).
 *
 * Consumer: sf-run startup sequence and `sf extensions sync` command.
 */
export function ensureRegistryEntries(extensionsDir: string): void {
	const manifests = discoverAllManifests(extensionsDir);
	if (manifests.size === 0) return;

	const registry = loadRegistry();
	let changed = false;

	for (const [id, _manifest] of manifests) {
		if (!registry.entries[id]) {
			registry.entries[id] = {
				id,
				enabled: true,
				source: "bundled",
			};
			changed = true;
		}
	}

	if (changed) {
		saveRegistry(registry);
	}
}
