/// <reference path="./lib/fresh.d.ts" />

import {
  hintBar,
  list,
  parseHintString,
  WidgetPanel,
} from "./lib/index.ts";

/**
 * Fresh Package Manager Plugin
 *
 * A decentralized, git-based package manager for Fresh plugins and themes.
 * Inspired by Emacs straight.el and Neovim lazy.nvim.
 *
 * Features:
 * - Install plugins/themes from any git repository
 * - Update packages via git pull
 * - Optional curated registry (also a git repo)
 * - Version pinning with tags, branches, or commits
 * - Lockfile for reproducibility
 *
 * TODO: Plugin UI Component Library
 * ---------------------------------
 * The UI code in this plugin manually constructs buttons, lists, split views,
 * and focus management using raw text property entries. This is verbose and
 * error-prone. We need a shared UI component library that plugins can use to
 * build interfaces in virtual buffers:
 *
 * - Buttons, lists, scroll bars, tabs, split views, text inputs, etc.
 * - Automatic keyboard navigation and focus management
 * - Theme-aware styling
 *
 * The editor's settings UI already implements similar components - these could
 * be unified into a shared framework. See PLUGIN_MARKETPLACE_DESIGN.md for details.
 */

import { Finder } from "./lib/finder.ts";

const editor = getEditor();

// The package manager operates entirely on editor-owned state — installed
// packages under the config dir and download scratch under the temp dir — which
// live on the local editor host regardless of the active window's authority
// (e.g. an SSH session). Route every filesystem call through `localPath(...)`
// so installs/uninstalls always hit the host, never a remote backend.
const fsLocal = {
  readFile: (p: string) => editor.readFile(editor.localPath(p)),
  writeFile: (p: string, content: string) =>
    editor.writeFile(editor.localPath(p), content),
  readDir: (p: string) => editor.readDir(editor.localPath(p)),
  fileExists: (p: string) => editor.fileExists(editor.localPath(p)),
  createDir: (p: string) => editor.createDir(editor.localPath(p)),
  removePath: (p: string) => editor.removePath(editor.localPath(p)),
  renamePath: (from: string, to: string) =>
    editor.renamePath(editor.localPath(from), editor.localPath(to)),
  copyPath: (from: string, to: string) =>
    editor.copyPath(editor.localPath(from), editor.localPath(to)),
};

// =============================================================================
// Configuration
// =============================================================================

const CONFIG_DIR = editor.getConfigDir();
const PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "plugins", "packages");
const THEMES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "themes", "packages");
const LANGUAGES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "languages", "packages");
const BUNDLES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "bundles", "packages");
const INDEX_DIR = editor.pathJoin(PACKAGES_DIR, ".index");
const CACHE_DIR = editor.pathJoin(PACKAGES_DIR, ".cache");
const LOCKFILE_PATH = editor.pathJoin(CONFIG_DIR, "fresh.lock");

// Default registry source
const DEFAULT_REGISTRY = "https://github.com/sinelaw/fresh-plugins-registry";

// =============================================================================
// Types
// =============================================================================

// TODO: Generate PackageManifest from the JSON schema (or vice versa) to ensure
// pkg.ts types stay in sync with package.schema.json. Consider using json-schema-to-typescript
// or ts-json-schema-generator to automate this.
// Related files:
//   - docs/internal/package-index-template/schemas/package.schema.json
//   - crates/fresh-editor/plugins/schemas/package.schema.json

// Bundle language definition (used in fresh.languages[])
interface BundleLanguage {
  /** Language identifier (e.g., 'elixir', 'heex') */
  id: string;
  /** Grammar configuration */
  grammar?: {
    file: string;
    extensions?: string[];
    firstLine?: string;
  };
  /** Language configuration */
  language?: {
    commentPrefix?: string;
    blockCommentStart?: string;
    blockCommentEnd?: string;
    useTabs?: boolean;
    tabSize?: number;
    autoIndent?: boolean;
    showWhitespaceTabs?: boolean;
    formatter?: {
      command: string;
      args?: string[];
    };
  };
  /** LSP server configuration */
  lsp?: {
    command: string;
    args?: string[];
    autoStart?: boolean;
    initializationOptions?: Record<string, unknown>;
    processLimits?: ProcessLimitsPackConfig;
  };
}

// Bundle plugin definition (used in fresh.plugins[])
interface BundlePlugin {
  /** Plugin entry point file relative to package */
  entry: string;
}

// Bundle theme definition (used in fresh.themes[])
interface BundleTheme {
  /** Theme JSON file path relative to package */
  file: string;
  /** Display name for the theme */
  name: string;
  /** Theme variant (dark or light) */
  variant?: "dark" | "light";
}

interface PackageManifest {
  name: string;
  version: string;
  description: string;
  type: "plugin" | "theme" | "theme-pack" | "language" | "bundle";
  author?: string;
  license?: string;
  repository?: string;
  fresh?: {
    min_version?: string;
    min_api_version?: number;
    entry?: string;
    themes?: Array<{
      file: string;
      name: string;
      variant?: "dark" | "light";
    }>;
    config_schema?: Record<string, unknown>;

    // Language pack fields
    grammar?: {
      /** Path to grammar file relative to package */
      file: string;
      /** File extensions (e.g., ["rs", "rust"]) */
      extensions?: string[];
      /** Shebang pattern for detection */
      firstLine?: string;
    };
    language?: {
      commentPrefix?: string;
      blockCommentStart?: string;
      blockCommentEnd?: string;
      useTabs?: boolean;
      tabSize?: number;
      autoIndent?: boolean;
      showWhitespaceTabs?: boolean;
      formatter?: {
        command: string;
        args?: string[];
      };
    };
    lsp?: {
      command: string;
      args?: string[];
      autoStart?: boolean;
      initializationOptions?: Record<string, unknown>;
      processLimits?: ProcessLimitsPackConfig;
    };

    // Bundle fields
    /** Languages included in this bundle */
    languages?: BundleLanguage[];
    /** Plugins included in this bundle */
    plugins?: BundlePlugin[];
  };
  keywords?: string[];
}

interface RegistryEntry {
  description: string;
  repository: string;
  author?: string;
  license?: string;
  keywords?: string[];
  stars?: number;
  downloads?: number;
  latest_version?: string;
  fresh_min_version?: string;
  variants?: string[];
}

interface RegistryData {
  schema_version: number;
  updated: string;
  packages: Record<string, RegistryEntry>;
}

interface InstalledPackage {
  name: string;
  path: string;
  type: "plugin" | "theme" | "language" | "bundle";
  source: string;
  version: string;
  commit?: string;
  manifest?: PackageManifest;
  /** Original local path if installed from a local directory */
  localSource?: string;
  /**
   * URL or path this package was installed from, as recorded in
   * `.fresh-source.json`. This is what an update re-fetches: unlike
   * `source` (a git remote, which monorepo-subpath and local-directory
   * installs don't have) it is written by every install path.
   */
  installedFrom?: string;
}

interface LockfileEntry {
  source: string;
  commit: string;
  version: string;
  integrity?: string;
}

interface Lockfile {
  lockfile_version: number;
  generated: string;
  packages: Record<string, LockfileEntry>;
}

// =============================================================================
// Types for URL parsing
// =============================================================================

interface ParsedPackageUrl {
  /** The base git repository URL or local path (without fragment) */
  repoUrl: string;
  /** Optional path within the repository/directory (from fragment) */
  subpath: string | null;
  /** Extracted package name */
  name: string;
  /** Whether this is a local file path (not a remote URL) */
  isLocal: boolean;
  /**
   * Whether the URL points directly to a single downloadable file (e.g. a
   * theme JSON) rather than to a git repository. When true, the URL should
   * be fetched over HTTP instead of cloned with git.
   */
  isDirectFile: boolean;
}

// =============================================================================
// Utility Functions
// =============================================================================

/**
 * Ensure a directory exists (cross-platform)
 */
function ensureDir(path: string): boolean {
  return fsLocal.createDir(path);
}

/**
 * Hash a string (simple djb2 hash for source identification)
 */
function hashString(str: string): string {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) + hash) + str.charCodeAt(i);
  }
  return Math.abs(hash).toString(16).slice(0, 8);
}

/**
 * Run a git command without prompting for credentials.
 * Uses git config options to prevent interactive prompts (cross-platform).
 */
async function gitCommand(args: string[]): Promise<{ exit_code: number; stdout: string; stderr: string }> {
  // Use git config options to disable credential prompts (works on Windows and Unix)
  // -c credential.helper= disables credential helper
  // -c core.askPass= disables askpass program
  const gitArgs = [
    "-c", "credential.helper=",
    "-c", "core.askPass=",
    ...args
  ];
  const result = await editor.spawnProcess("git", gitArgs);
  return result;
}

/**
 * Turn a failed git invocation's stderr into one line worth showing.
 *
 * Prefers git's first `fatal:` / `error:` line over the first line of
 * output: git opens with progress noise ("Cloning into '/data/data/com.termux/files/usr/tmp/...'...")
 * and trails off into advice ("...and the repository exists."), and
 * this text is what the package browser shows the user when an install
 * or update fails.
 */
function gitErrorMessage(stderr: string, fallback: string): string {
  if (stderr.includes("Could not resolve host")) return "Network error";
  if (stderr.includes("not found") || stderr.includes("404")) return "Repository not found";
  if (stderr.includes("Authentication") || stderr.includes("403")) {
    return "Access denied (repository may be private)";
  }
  const lines = stderr.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
  for (const line of lines) {
    const match = line.match(/^(?:fatal|error):\s*(.+)$/);
    if (match) return match[1];
  }
  return lines[0] || fallback;
}

/**
 * Check if a string is a local file path (not a URL).
 */
function isLocalPath(str: string): boolean {
  // Absolute paths start with /
  if (str.startsWith("/")) return true;
  // Windows absolute paths (C:\, D:\, etc.)
  if (/^[A-Za-z]:[\\\/]/.test(str)) return true;
  // Relative paths starting with . or ..
  if (str.startsWith("./") || str.startsWith("../")) return true;
  // Home directory expansion
  if (str.startsWith("~/")) return true;
  // Not a URL scheme (http://, https://, git://, ssh://, file://)
  if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(str)) {
    // If it doesn't look like a URL and doesn't contain @, it's probably a path
    // (git@github.com:user/repo is a git URL)
    if (!str.includes("@") || str.startsWith("/")) {
      return true;
    }
  }
  return false;
}

/**
 * File extensions that indicate a URL points to a single downloadable file
 * (e.g. a Fresh theme JSON) rather than to a git repository.
 */
const DIRECT_FILE_EXTENSIONS = /\.(jsonc?|JSONC?)$/;

/**
 * Check if a URL points directly to a single downloadable file rather than
 * to a git repository. Examples:
 * - `https://github.com/user-attachments/files/123/synthwave-84.json` → true
 * - `https://github.com/user/repo` → false
 */
function isDirectFileUrl(url: string): boolean {
  // Strip query string for extension check
  const pathOnly = url.split("?")[0];
  return DIRECT_FILE_EXTENSIONS.test(pathOnly);
}

/**
 * Parse a package URL that may contain a subpath fragment.
 *
 * Supported formats:
 * - `https://github.com/user/repo` - standard repo
 * - `https://github.com/user/repo#path/to/plugin` - monorepo with subpath
 * - `https://github.com/user/repo.git#packages/my-plugin` - with .git suffix
 * - `/path/to/local/repo#subdir` - local path with subpath
 * - `/path/to/local/package` - direct local package path
 * - `https://example.com/path/to/theme.json` - direct file (downloaded over HTTP)
 *
 * The fragment (after #) specifies a subdirectory within the repo.
 */
function parsePackageUrl(url: string): ParsedPackageUrl {
  // Split on # to get subpath
  const hashIndex = url.indexOf("#");
  let repoUrl: string;
  let subpath: string | null = null;

  if (hashIndex !== -1) {
    repoUrl = url.slice(0, hashIndex);
    subpath = url.slice(hashIndex + 1);
    // Clean up subpath - remove leading/trailing slashes
    subpath = subpath.replace(/^\/+|\/+$/g, "");
    if (subpath === "") {
      subpath = null;
    }
  } else {
    repoUrl = url;
  }

  // Determine if this is a local path
  const isLocal = isLocalPath(repoUrl);

  // A remote URL ending in a recognized file extension (and without a
  // subpath fragment) is treated as a direct file download, not a git repo.
  const isDirectFile = !isLocal && !subpath && isDirectFileUrl(repoUrl);

  // Extract package name
  let name: string;
  if (subpath) {
    // For monorepo/directory, use the last component of the subpath
    const parts = subpath.split("/");
    name = parts[parts.length - 1].replace(/^fresh-/, "");
  } else if (isDirectFile) {
    // For direct file downloads, use the filename without the extension
    const pathOnly = repoUrl.split("?")[0];
    const match = pathOnly.match(/\/([^\/]+?)\.(jsonc?|JSONC?)$/);
    name = match ? match[1].replace(/^fresh-/, "") : "unknown";
  } else {
    // For regular repo/path, use the last component
    const match = repoUrl.match(/\/([^\/]+?)(\.git)?$/);
    name = match ? match[1].replace(/^fresh-/, "") : "unknown";
  }

  return { repoUrl, subpath, name, isLocal, isDirectFile };
}

/**
 * Extract package name from git URL (legacy helper)
 */
function extractPackageName(url: string): string {
  return parsePackageUrl(url).name;
}

/**
 * Get registry sources from config
 */
function getRegistrySources(): string[] {
  const config = editor.getConfig() as Record<string, unknown>;
  const packages = config?.packages as Record<string, unknown> | undefined;
  const sources = packages?.sources as string[] | undefined;
  return sources && sources.length > 0 ? sources : [DEFAULT_REGISTRY];
}

/**
 * Read and parse a JSON file
 */
function readJsonFile<T>(path: string): T | null {
  try {
    const content = fsLocal.readFile(path);
    if (content) {
      return JSON.parse(content) as T;
    }
  } catch (e) {
    editor.debug(`[pkg] Failed to read JSON file ${path}: ${e}`);
  }
  return null;
}

/**
 * Write a JSON file
 */
async function writeJsonFile(path: string, data: unknown): Promise<boolean> {
  try {
    const content = JSON.stringify(data, null, 2);
    return fsLocal.writeFile(path, content);
  } catch (e) {
    editor.debug(`[pkg] Failed to write JSON file ${path}: ${e}`);
    return false;
  }
}

// =============================================================================
// Registry Operations
// =============================================================================

/**
 * Sync registry sources
 */
async function syncRegistry(): Promise<void> {
  editor.setStatus("Syncing package registry...");

  ensureDir(INDEX_DIR);

  const sources = getRegistrySources();
  let synced = 0;
  const errors: string[] = [];

  for (const source of sources) {
    const indexPath = editor.pathJoin(INDEX_DIR, hashString(source));

    if (fsLocal.fileExists(indexPath)) {
      // Update existing
      editor.setStatus(`Updating registry: ${source}...`);
      const result = await gitCommand(["-C", `${indexPath}`, "pull", "--ff-only"]);
      if (result.exit_code === 0) {
        synced++;
      } else {
        const errorMsg = result.stderr.includes("Could not resolve host")
          ? "Network error"
          : result.stderr.includes("Authentication") || result.stderr.includes("403")
          ? "Authentication failed (check if repo is public)"
          : result.stderr.split("\n")[0] || "Unknown error";
        errors.push(`${source}: ${errorMsg}`);
        editor.warn(`[pkg] Failed to update registry ${source}: ${result.stderr}`);
      }
    } else {
      // Clone new
      editor.setStatus(`Cloning registry: ${source}...`);
      const result = await gitCommand(["clone", "--depth", "1", `${source}`, `${indexPath}`]);
      if (result.exit_code === 0) {
        synced++;
      } else {
        const errorMsg = result.stderr.includes("Could not resolve host")
          ? "Network error"
          : result.stderr.includes("not found") || result.stderr.includes("404")
          ? "Repository not found"
          : result.stderr.includes("Authentication") || result.stderr.includes("403")
          ? "Authentication failed (check if repo is public)"
          : result.stderr.split("\n")[0] || "Unknown error";
        errors.push(`${source}: ${errorMsg}`);
        editor.warn(`[pkg] Failed to clone registry ${source}: ${result.stderr}`);
      }
    }
  }

  // Cache registry data locally for faster startup next time
  if (synced > 0) {
    await cacheRegistry();
  }

  if (errors.length > 0) {
    editor.setStatus(`Registry: ${synced}/${sources.length} synced. Errors: ${errors.join("; ")}`);
  } else {
    editor.setStatus(`Registry synced (${synced}/${sources.length} sources)`);
  }
}

/**
 * Load merged registry data from git index or cache
 */
function loadRegistry(type: "plugins" | "themes" | "languages"): RegistryData {
  editor.debug(`[pkg] loadRegistry called for ${type}`);
  const sources = getRegistrySources();
  editor.debug(`[pkg] sources: ${JSON.stringify(sources)}`);
  const merged: RegistryData = {
    schema_version: 1,
    updated: new Date().toISOString(),
    packages: {}
  };

  for (const source of sources) {
    // Try git index first
    const indexPath = editor.pathJoin(INDEX_DIR, hashString(source), `${type}.json`);
    editor.debug(`[pkg] checking index path: ${indexPath}`);
    let data = readJsonFile<RegistryData>(indexPath);

    // Fall back to cache if index not available
    if (!data?.packages) {
      const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_${type}.json`);
      data = readJsonFile<RegistryData>(cachePath);
      if (data?.packages) {
        editor.debug(`[pkg] using cached data for ${type}`);
      }
    }

    editor.debug(`[pkg] data loaded: ${data ? 'yes' : 'no'}, packages: ${data?.packages ? Object.keys(data.packages).length : 0}`);
    if (data?.packages) {
      Object.assign(merged.packages, data.packages);
    }
  }

  editor.debug(`[pkg] total merged packages: ${Object.keys(merged.packages).length}`);
  return merged;
}

/**
 * Cache registry data locally for offline/fast access
 */
async function cacheRegistry(): Promise<void> {
  ensureDir(CACHE_DIR);
  const sources = getRegistrySources();

  for (const source of sources) {
    const sourceHash = hashString(source);
    for (const type of ["plugins", "themes", "languages"] as const) {
      const indexPath = editor.pathJoin(INDEX_DIR, sourceHash, `${type}.json`);
      const cachePath = editor.pathJoin(CACHE_DIR, `${sourceHash}_${type}.json`);

      const data = readJsonFile<RegistryData>(indexPath);
      if (data?.packages && Object.keys(data.packages).length > 0) {
        await writeJsonFile(cachePath, data);
      }
    }
  }
}

/**
 * Check if registry data is available (from index or cache)
 */
function isRegistrySynced(): boolean {
  const sources = getRegistrySources();
  for (const source of sources) {
    // Check git index
    const indexPath = editor.pathJoin(INDEX_DIR, hashString(source));
    if (fsLocal.fileExists(indexPath)) {
      return true;
    }
    // Check cache
    const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_plugins.json`);
    if (fsLocal.fileExists(cachePath)) {
      return true;
    }
  }
  return false;
}

// =============================================================================
// Package Operations
// =============================================================================

/**
 * Get list of installed packages
 */
function getInstalledPackages(type: "plugin" | "theme" | "language" | "bundle"): InstalledPackage[] {
  const packagesDir = type === "plugin" ? PACKAGES_DIR
                    : type === "theme" ? THEMES_PACKAGES_DIR
                    : type === "bundle" ? BUNDLES_PACKAGES_DIR
                    : LANGUAGES_PACKAGES_DIR;
  const packages: InstalledPackage[] = [];

  if (!fsLocal.fileExists(packagesDir)) {
    return packages;
  }

  try {
    const entries = fsLocal.readDir(packagesDir);
    for (const entry of entries) {
      if (entry.is_dir && !entry.name.startsWith(".")) {
        const pkgPath = editor.pathJoin(packagesDir, entry.name);
        const manifestPath = editor.pathJoin(pkgPath, "package.json");
        const manifest = readJsonFile<PackageManifest>(manifestPath);

        // Try to get git remote
        const gitConfigPath = editor.pathJoin(pkgPath, ".git", "config");
        let source = "";
        let localSource: string | undefined;
        if (fsLocal.fileExists(gitConfigPath)) {
          const gitConfig = fsLocal.readFile(gitConfigPath);
          if (gitConfig) {
            const match = gitConfig.match(/url\s*=\s*(.+)/);
            if (match) {
              source = match[1].trim();
            }
          }
        }

        // Every install path writes .fresh-source.json; read it whatever
        // the git remote said, because the marker is the only record a
        // monorepo-subpath or local-directory install leaves behind (they
        // are copies, so they have no .git at all).
        const freshSourcePath = editor.pathJoin(pkgPath, ".fresh-source.json");
        const freshSource = readJsonFile<{
          local_path?: string;
          original_url?: string;
          url?: string;
          repository?: string;
          subpath?: string;
          installed_from?: string;
        }>(freshSourcePath);
        if (freshSource?.local_path) {
          localSource = freshSource.local_path;
        }
        const installedFrom = freshSource?.installed_from
          || freshSource?.original_url
          || freshSource?.url
          || freshSource?.local_path
          || undefined;
        if (!source && installedFrom) {
          source = installedFrom;
        }

        packages.push({
          // The manifest names the package; the directory is only
          // where it happens to live. They match for anything the
          // installer put there, but the manifest is what a registry
          // entry would be keyed by, so prefer it.
          name: manifest?.name || entry.name,
          path: pkgPath,
          type,
          source,
          version: manifest?.version || "unknown",
          manifest: manifest ?? undefined,
          localSource,
          installedFrom,
        });
      }
    }
  } catch (e) {
    editor.debug(`[pkg] Failed to list packages: ${e}`);
  }

  return packages;
}

/**
 * Every installed package, of every kind.
 *
 * The manager's commands used to look at plugins and themes only, so a
 * language pack or bundle — which `Package: Install from URL` installs
 * just as readily — was listed in the browser but invisible to Update,
 * Remove, Update All and the lockfile.
 */
function getAllInstalledPackages(): InstalledPackage[] {
  return [
    ...getInstalledPackages("plugin"),
    ...getInstalledPackages("theme"),
    ...getInstalledPackages("language"),
    ...getInstalledPackages("bundle"),
  ];
}

/**
 * Validation result for a package
 */
interface ValidationResult {
  valid: boolean;
  error?: string;
  manifest?: PackageManifest;
  entryPath?: string;
}

/**
 * Validate a package directory has correct structure
 *
 * Checks:
 * 1. package.json exists
 * 2. package.json has required fields (name, type)
 * 3. Entry file exists (for plugins)
 */
function validatePackage(packageDir: string, packageName: string): ValidationResult {
  const manifestPath = editor.pathJoin(packageDir, "package.json");

  // Check package.json exists
  if (!fsLocal.fileExists(manifestPath)) {
    return {
      valid: false,
      error: `Missing package.json - expected at ${manifestPath}`
    };
  }

  // Read and validate manifest
  const manifest = readJsonFile<PackageManifest>(manifestPath);
  if (!manifest) {
    return {
      valid: false,
      error: "Invalid package.json - could not parse JSON"
    };
  }

  // Validate required fields
  if (!manifest.name) {
    return {
      valid: false,
      error: "Invalid package.json - missing 'name' field"
    };
  }

  if (!manifest.type) {
    return {
      valid: false,
      error: "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', 'language', or 'bundle')"
    };
  }

  if (manifest.type !== "plugin" && manifest.type !== "theme" && manifest.type !== "language" && manifest.type !== "bundle") {
    return {
      valid: false,
      error: `Invalid package.json - 'type' must be 'plugin', 'theme', 'language', or 'bundle', got '${manifest.type}'`
    };
  }

  // Warn if package requires a newer plugin API version
  if (manifest.fresh?.min_api_version) {
    const currentApi = editor.apiVersion();
    if (manifest.fresh.min_api_version > currentApi) {
      editor.warn(
        `[pkg] Package '${packageName}' requires plugin API version ${manifest.fresh.min_api_version}, ` +
        `but this editor only supports version ${currentApi}. Some features may not work. ` +
        `Update Fresh to get the latest plugin API.`
      );
    }
  }

  // For plugins, validate entry file exists
  if (manifest.type === "plugin") {
    const entryFile = manifest.fresh?.entry || manifest.fresh?.main || `${manifest.name}.ts`;
    const entryPath = editor.pathJoin(packageDir, entryFile);

    if (!fsLocal.fileExists(entryPath)) {
      // Try .js as fallback
      const jsEntryPath = entryPath.replace(/\.ts$/, ".js");
      if (fsLocal.fileExists(jsEntryPath)) {
        return { valid: true, manifest, entryPath: jsEntryPath };
      }

      return {
        valid: false,
        error: `Missing entry file '${entryFile}' - check fresh.entry in package.json`
      };
    }

    return { valid: true, manifest, entryPath };
  }

  // For language packs, validate at least one component is defined
  if (manifest.type === "language") {
    if (!manifest.fresh?.grammar && !manifest.fresh?.language && !manifest.fresh?.lsp) {
      return {
        valid: false,
        error: "Language package must define at least one of: grammar, language, or lsp"
      };
    }

    // Validate grammar file exists if specified
    if (manifest.fresh?.grammar?.file) {
      const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file);
      if (!fsLocal.fileExists(grammarPath)) {
        return {
          valid: false,
          error: `Grammar file not found: ${manifest.fresh.grammar.file}`
        };
      }
    }

    return { valid: true, manifest };
  }

  // For bundles, validate at least one language, plugin, or theme is defined
  if (manifest.type === "bundle") {
    const hasLanguages = manifest.fresh?.languages && manifest.fresh.languages.length > 0;
    const hasPlugins = manifest.fresh?.plugins && manifest.fresh.plugins.length > 0;
    const hasThemes = manifest.fresh?.themes && manifest.fresh.themes.length > 0;

    if (!hasLanguages && !hasPlugins && !hasThemes) {
      return {
        valid: false,
        error: "Bundle package must define at least one language, plugin, or theme"
      };
    }

    // Validate each language entry
    if (manifest.fresh?.languages) {
      for (const lang of manifest.fresh.languages) {
        if (!lang.id) {
          return {
            valid: false,
            error: "Bundle language entry missing required 'id' field"
          };
        }
        // Validate grammar file exists if specified
        if (lang.grammar?.file) {
          const grammarPath = editor.pathJoin(packageDir, lang.grammar.file);
          if (!fsLocal.fileExists(grammarPath)) {
            return {
              valid: false,
              error: `Grammar file not found for language '${lang.id}': ${lang.grammar.file}`
            };
          }
        }
      }
    }

    // Validate each plugin entry
    if (manifest.fresh?.plugins) {
      for (const plugin of manifest.fresh.plugins) {
        if (!plugin.entry) {
          return {
            valid: false,
            error: "Bundle plugin entry missing required 'entry' field"
          };
        }
        const entryPath = editor.pathJoin(packageDir, plugin.entry);
        if (!fsLocal.fileExists(entryPath)) {
          // Try .js as fallback
          const jsEntryPath = entryPath.replace(/\.ts$/, ".js");
          if (!fsLocal.fileExists(jsEntryPath)) {
            return {
              valid: false,
              error: `Plugin entry file not found: ${plugin.entry}`
            };
          }
        }
      }
    }

    return { valid: true, manifest };
  }

  // Themes don't need entry file validation
  return { valid: true, manifest };
}

/**
 * Read the `version` recorded in an installed package's manifest, or
 * `null` when the directory holds no readable manifest. Used to name
 * the version an upgrade replaced.
 */
function installedVersion(packageDir: string): string | null {
  const manifest = readJsonFile<PackageManifest>(
    editor.pathJoin(packageDir, "package.json"),
  );
  return manifest?.version || null;
}

/**
 * Unload every plugin running out of `packageDir`.
 *
 * Must run before an installed directory is replaced: deleting files
 * under a loaded plugin leaves the old copy running for the rest of
 * the session. Matches by path prefix rather than by package name
 * (the runtime plugin name comes from the entry file), and unloads
 * every match so a bundle's several plugins all come down.
 */
async function unloadPluginsUnder(packageDir: string): Promise<void> {
  const loadedPlugins = await editor.listPlugins();
  for (const plugin of loadedPlugins) {
    if (plugin.path.startsWith(packageDir)) {
      await editor.unloadPlugin(plugin.name).catch(() => {});
    }
  }
}

/**
 * Move a freshly fetched, already-validated package into its install
 * directory, replacing any copy already there.
 *
 * Callers validate the staging copy first and unload anything running
 * out of the target, so by the time this runs the replacement is known
 * good. The existing copy is renamed aside and only deleted once the
 * new one is in place; a failed swap puts it back, so an upgrade can
 * never leave the user without a working install. The backup name
 * starts with a dot so `getInstalledPackages` never sees it as a
 * package of its own.
 *
 * `move` renames the staging directory in (the git-clone paths, whose
 * staging lives under the temp dir); otherwise it is copied, leaving
 * the source intact (local-path installs).
 */
function swapInstalledDir(
  stagingDir: string,
  packagesDir: string,
  packageName: string,
  move: boolean,
): boolean {
  const targetDir = editor.pathJoin(packagesDir, packageName);
  const backupDir = editor.pathJoin(
    packagesDir,
    `.${packageName}.replaced-${Date.now()}`,
  );

  const hadExisting = fsLocal.fileExists(targetDir);
  if (hadExisting && !fsLocal.renamePath(targetDir, backupDir)) {
    return false;
  }

  const placed = move
    ? fsLocal.renamePath(stagingDir, targetDir)
    : fsLocal.copyPath(stagingDir, targetDir);

  if (!placed) {
    // A directory copy that fails part-way still leaves what it managed
    // to write. Clear it: a half-copied package left at the install
    // path is scanned as an installed one and shows up in the browser
    // as if the install had worked.
    fsLocal.removePath(targetDir);
    if (hadExisting) {
      fsLocal.renamePath(backupDir, targetDir);
    }
    return false;
  }

  if (hadExisting) {
    fsLocal.removePath(backupDir);
  }
  return true;
}

/**
 * Status line for a finished install. `previousVersion` is `null` for a
 * first install and the replaced version otherwise, so an upgrade reads
 * as one ("Upgraded file-diff 1.0.0 → 1.1.0"). Re-running an install at
 * the version already on disk is a deliberate forced refresh (the
 * source may have moved without the version changing), reported as a
 * reinstall rather than silently doing nothing.
 */
function installedStatus(
  action: string,
  packageName: string,
  previousVersion: string | null,
  version: string,
): string {
  if (previousVersion === null) {
    return `${action} ${packageName} v${version}`;
  }
  if (previousVersion === version) {
    return `Reinstalled ${packageName} v${version}`;
  }
  return `Upgraded ${packageName} ${previousVersion} → ${version}`;
}

/**
 * The most recent install / update / uninstall failure.
 *
 * Progress messages belong on the status bar, but a failure the user
 * has to act on must not vanish the next time anything else writes
 * there — the package browser reads this to show the failure in its
 * own panel. Cleared by `runPackageAction` when a new action starts.
 */
let lastPackageError: string | null = null;

/**
 * Report a failed package operation: to the status bar as before, and
 * into `lastPackageError` so the browser UI can surface it too.
 */
function packageFailure(message: string): false {
  lastPackageError = message;
  editor.setStatus(message);
  return false;
}

/**
 * Install a package from a git URL, direct file URL, or local path.
 *
 * Supports:
 * - `https://github.com/user/repo` - standard git repo
 * - `https://github.com/user/repo#packages/my-plugin` - monorepo with subpath
 * - `https://example.com/path/to/theme.json` - direct file download (theme JSON)
 * - `/path/to/local/repo#subdir` - local path with subpath
 * - `/path/to/local/package` - direct local package path
 *
 * For subpath packages, clones/copies to temp directory and copies the subdirectory.
 */
async function installPackage(
  url: string,
  name?: string,
  _type?: "plugin" | "theme" | "language" | "bundle",  // Ignored - type is auto-detected from manifest
  version?: string
): Promise<boolean> {
  const parsed = parsePackageUrl(url);
  const packageName = name || parsed.name;

  editor.setStatus(`Installing ${packageName}...`);

  if (parsed.isLocal) {
    // Local path installation: copy directly
    return await installFromLocalPath(parsed, packageName);
  } else if (parsed.isDirectFile) {
    // Direct file URL (e.g. theme JSON): download with curl, not git clone
    return await installFromDirectFile(parsed.repoUrl, packageName);
  } else if (parsed.subpath) {
    // Remote monorepo installation: clone to temp, copy subdirectory
    return await installFromMonorepo(parsed, packageName, version);
  } else {
    // Standard git installation: clone directly
    return await installFromRepo(parsed.repoUrl, packageName, version);
  }
}

/**
 * Install from a direct file URL (e.g. a single theme JSON hosted on a CDN
 * or GitHub user-attachments). Downloads the file with curl, validates it
 * looks like a Fresh package (currently themes only), and writes it into
 * the appropriate packages directory with a synthetic package.json manifest.
 *
 * Supported file types:
 * - `.json` / `.jsonc` containing a Fresh theme (object with a `name` field
 *   and at least one theme section like `editor`, `ui`, `syntax`, etc.)
 */
async function installFromDirectFile(
  url: string,
  packageName: string
): Promise<boolean> {
  const tempFile = editor.pathJoin(
    editor.getTempDir(),
    `fresh-pkg-file-${hashString(url)}-${Date.now()}.json`
  );

  editor.setStatus(`Downloading ${url}...`);
  const result = await editor.httpFetch(url, tempFile);

  if (result.exit_code !== 0) {
    // exit_code mirrors editor.httpFetch's contract: 0 on 2xx, the HTTP
    // status code on non-2xx, -1 on transport errors.
    const errorMsg = result.exit_code === 404
      ? "File not found"
      : result.exit_code > 0
      ? `HTTP ${result.exit_code}`
      : result.stderr.split("\n")[0] || "Download failed";
    packageFailure(`Failed to download ${packageName}: ${errorMsg}`);
    fsLocal.removePath(tempFile);
    return false;
  }

  const content = fsLocal.readFile(tempFile);
  if (!content) {
    packageFailure(`Failed to read downloaded file`);
    fsLocal.removePath(tempFile);
    return false;
  }

  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(content) as Record<string, unknown>;
  } catch (e) {
    packageFailure(`Downloaded file is not valid JSON: ${e}`);
    fsLocal.removePath(tempFile);
    return false;
  }

  // Heuristic: a Fresh theme JSON has a `name` string and at least one of
  // the recognized theme sections. This rules out arbitrary JSON files
  // (configs, package manifests, etc.) being installed as themes.
  const themeName = typeof parsed.name === "string" ? parsed.name : null;
  const looksLikeTheme = themeName !== null && (
    parsed.editor !== undefined ||
    parsed.ui !== undefined ||
    parsed.syntax !== undefined ||
    parsed.search !== undefined ||
    parsed.diagnostics !== undefined ||
    parsed.base !== undefined
  );

  if (!looksLikeTheme) {
    packageFailure(
      `Unrecognized file format at ${url} - direct file install currently supports Fresh theme JSON only`
    );
    fsLocal.removePath(tempFile);
    return false;
  }

  // Use the theme's own name as the package name when available.
  if (themeName) packageName = themeName;

  // Sanitize for use as a directory name.
  const safeName = packageName.replace(/[^a-zA-Z0-9_.-]/g, "-");
  const targetDir = editor.pathJoin(THEMES_PACKAGES_DIR, safeName);
  // Remembered before anything is replaced so the finished install can
  // report an upgrade; `null` when this is a first install.
  const previousVersion = fsLocal.fileExists(targetDir)
    ? installedVersion(targetDir)
    : null;

  // Stage the whole package in the temp dir first. Only once it is
  // complete does `swapInstalledDir` touch the installed copy, so a
  // half-written theme can never replace a working one.
  const stagingDir = editor.pathJoin(
    editor.getTempDir(),
    `fresh-pkg-theme-${hashString(url)}-${Date.now()}`,
  );
  if (!ensureDir(stagingDir)) {
    packageFailure(`Failed to create staging directory ${stagingDir}`);
    fsLocal.removePath(tempFile);
    return false;
  }

  const themeFileName = "theme.json";
  if (!fsLocal.writeFile(editor.pathJoin(stagingDir, themeFileName), content)) {
    packageFailure(`Failed to write theme file`);
    fsLocal.removePath(tempFile);
    fsLocal.removePath(stagingDir);
    return false;
  }

  const manifest: PackageManifest = {
    name: safeName,
    version: previousVersion ?? "1.0.0",
    description: `Theme installed from ${url}`,
    type: "theme",
    fresh: {
      themes: [{ file: themeFileName, name: themeName ?? safeName }],
    },
  };
  if (!await writeJsonFile(editor.pathJoin(stagingDir, "package.json"), manifest)) {
    packageFailure(`Failed to write package manifest`);
    fsLocal.removePath(tempFile);
    fsLocal.removePath(stagingDir);
    return false;
  }

  await writeJsonFile(editor.pathJoin(stagingDir, ".fresh-source.json"), {
    url,
    installed_from: url,
    installed_at: new Date().toISOString(),
  });

  ensureDir(THEMES_PACKAGES_DIR);
  if (!swapInstalledDir(stagingDir, THEMES_PACKAGES_DIR, safeName, true)) {
    packageFailure(`Failed to install ${safeName}: could not replace ${targetDir}`);
    fsLocal.removePath(tempFile);
    fsLocal.removePath(stagingDir);
    return false;
  }

  fsLocal.removePath(tempFile);
  editor.reloadThemes();
  editor.setStatus(
    installedStatus("Installed theme", themeName ?? safeName, previousVersion, manifest.version),
  );
  return true;
}

/**
 * Install from a standard git repository (no subpath)
 * Clones to temp first to detect type, then moves to correct location.
 */
async function installFromRepo(
  repoUrl: string,
  packageName: string,
  version?: string
): Promise<boolean> {
  // Clone to temp directory first to detect package type
  const tempDir = editor.pathJoin(editor.getTempDir(), `fresh-pkg-clone-${hashString(repoUrl)}-${Date.now()}`);

  const cloneArgs = ["clone"];
  if (!version || version === "latest") {
    cloneArgs.push("--depth", "1");
  }
  cloneArgs.push(`${repoUrl}`, `${tempDir}`);

  const result = await gitCommand(cloneArgs);

  if (result.exit_code !== 0) {
    const errorMsg = gitErrorMessage(result.stderr, "Clone failed");
    packageFailure(`Failed to install ${packageName}: ${errorMsg}`);
    return false;
  }

  // Checkout specific version if requested
  if (version && version !== "latest") {
    const checkoutResult = await checkoutVersion(tempDir, version);
    if (!checkoutResult) {
      editor.setStatus(`Installed ${packageName} but failed to checkout version ${version}`);
    }
  }

  // Validate package structure
  const validation = validatePackage(tempDir, packageName);
  if (!validation.valid) {
    editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
    packageFailure(`Failed to install ${packageName}: ${validation.error}`);
    // Clean up
    fsLocal.removePath(tempDir);
    return false;
  }

  const manifest = validation.manifest;

  // Use manifest name as the authoritative package name
  if (manifest?.name) packageName = manifest.name;

  // Determine correct target directory based on actual package type
  const actualType = manifest?.type || "plugin";
  const correctPackagesDir = actualType === "plugin" ? PACKAGES_DIR
                           : actualType === "theme" ? THEMES_PACKAGES_DIR
                           : actualType === "bundle" ? BUNDLES_PACKAGES_DIR
                           : LANGUAGES_PACKAGES_DIR;
  const correctTargetDir = editor.pathJoin(correctPackagesDir, packageName);

  // An existing installation is an upgrade, not a dead end. The clone
  // above is already fetched and validated, so replacing the old copy
  // from here can only fail on a filesystem error — a bad download
  // returned long before this point, leaving the install untouched.
  const previousVersion = fsLocal.fileExists(correctTargetDir)
    ? installedVersion(correctTargetDir)
    : null;

  // Record where this came from so the package manager can re-install
  // it later without re-deriving the URL from the clone's git remote.
  await writeJsonFile(editor.pathJoin(tempDir, ".fresh-source.json"), {
    repository: repoUrl,
    installed_from: repoUrl,
    installed_at: new Date().toISOString()
  });

  if (previousVersion !== null) {
    // Unload before replacing: files deleted under a loaded plugin
    // leave the old copy running for the rest of the session.
    await unloadPluginsUnder(correctTargetDir);
  }

  // Ensure correct directory exists and move from temp
  ensureDir(correctPackagesDir);
  if (!swapInstalledDir(tempDir, correctPackagesDir, packageName, true)) {
    packageFailure(`Failed to install ${packageName}: could not move package to target directory`);
    fsLocal.removePath(tempDir);
    return false;
  }

  const newVersion = manifest?.version ?? "unknown";

  // Dynamically load plugins, reload themes, load language packs, or load bundles
  if (manifest?.type === "plugin" && validation.entryPath) {
    // Update entry path to new location
    const newEntryPath = validation.entryPath.replace(tempDir, correctTargetDir);
    await editor.loadPlugin(newEntryPath);
    editor.setStatus(installedStatus("Installed and activated", packageName, previousVersion, newVersion));
  } else if (manifest?.type === "theme") {
    editor.reloadThemes();
    editor.setStatus(installedStatus("Installed theme", packageName, previousVersion, newVersion));
  } else if (manifest?.type === "language") {
    await loadLanguagePack(correctTargetDir, manifest);
    editor.setStatus(installedStatus("Installed language pack", packageName, previousVersion, newVersion));
  } else if (manifest?.type === "bundle") {
    await loadBundle(correctTargetDir, manifest);
    editor.setStatus(installedStatus("Installed bundle", packageName, previousVersion, newVersion));
  } else {
    editor.setStatus(installedStatus("Installed", packageName, previousVersion, newVersion));
  }
  return true;
}

/**
 * Install from a local file path.
 *
 * Strategy:
 * - If subpath is specified: copy that subdirectory
 * - Otherwise: copy the entire directory
 * - Store the source path for reference
 * - Auto-detect package type from manifest and install to correct directory
 */
async function installFromLocalPath(
  parsed: ParsedPackageUrl,
  packageName: string
): Promise<boolean> {
  // Resolve the full source path
  let sourcePath = parsed.repoUrl;

  // Handle home directory expansion
  if (sourcePath.startsWith("~/")) {
    const home = editor.getEnv("HOME") || editor.getEnv("USERPROFILE") || "";
    sourcePath = editor.pathJoin(home, sourcePath.slice(2));
  }

  // If there's a subpath, append it
  if (parsed.subpath) {
    sourcePath = editor.pathJoin(sourcePath, parsed.subpath);
  }

  // Check if source exists
  if (!fsLocal.fileExists(sourcePath)) {
    packageFailure(`Local path not found: ${sourcePath}`);
    return false;
  }

  // Check if it's a directory (by checking for package.json)
  const manifestPath = editor.pathJoin(sourcePath, "package.json");
  if (!fsLocal.fileExists(manifestPath)) {
    packageFailure(`Not a valid package (no package.json): ${sourcePath}`);
    return false;
  }

  // Read manifest FIRST to determine actual package type and name
  const manifest = readJsonFile<PackageManifest>(manifestPath);
  if (!manifest) {
    packageFailure(`Invalid package.json in ${sourcePath}`);
    return false;
  }

  // Use manifest name as the authoritative package name
  packageName = manifest.name;

  // Determine correct target directory based on actual package type
  const actualType = manifest.type || "plugin";
  const correctPackagesDir = actualType === "plugin" ? PACKAGES_DIR
                           : actualType === "theme" ? THEMES_PACKAGES_DIR
                           : actualType === "bundle" ? BUNDLES_PACKAGES_DIR
                           : LANGUAGES_PACKAGES_DIR;
  const correctTargetDir = editor.pathJoin(correctPackagesDir, packageName);

  // An existing installation is an upgrade, not a dead end.
  const previousVersion = fsLocal.fileExists(correctTargetDir)
    ? installedVersion(correctTargetDir)
    : null;

  // Ensure correct directory exists
  ensureDir(correctPackagesDir);

  // Copy into a staging directory and validate there, so a source that
  // turns out not to be a valid package never touches an installation
  // that already works.
  const stagingDir = editor.pathJoin(
    editor.getTempDir(),
    `fresh-pkg-local-${hashString(sourcePath)}-${Date.now()}`,
  );
  editor.setStatus(`Copying from ${sourcePath}...`);
  if (!fsLocal.copyPath(sourcePath, stagingDir)) {
    packageFailure(`Failed to copy package from ${sourcePath}`);
    return false;
  }

  // Validate package structure
  const validation = validatePackage(stagingDir, packageName);
  if (!validation.valid) {
    editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
    packageFailure(`Failed to install ${packageName}: ${validation.error}`);
    // Clean up the invalid package
    fsLocal.removePath(stagingDir);
    return false;
  }

  // Store the source path for reference
  const sourceInfo = {
    local_path: sourcePath,
    original_url: parsed.subpath ? `${parsed.repoUrl}#${parsed.subpath}` : parsed.repoUrl,
    installed_at: new Date().toISOString()
  };
  await writeJsonFile(editor.pathJoin(stagingDir, ".fresh-source.json"), sourceInfo);

  if (previousVersion !== null) {
    // Unload before replacing: files deleted under a loaded plugin
    // leave the old copy running for the rest of the session.
    await unloadPluginsUnder(correctTargetDir);
  }
  if (!swapInstalledDir(stagingDir, correctPackagesDir, packageName, true)) {
    packageFailure(`Failed to install ${packageName}: could not move package to target directory`);
    fsLocal.removePath(stagingDir);
    return false;
  }

  const newVersion = manifest.version || "unknown";
  // The validated entry path points into the staging directory; the
  // package now lives at its install path.
  const entryPath = validation.entryPath?.replace(stagingDir, correctTargetDir);

  // Dynamically load plugins, reload themes, load language packs, or load bundles
  if (manifest.type === "plugin" && entryPath) {
    await editor.loadPlugin(entryPath);
    editor.setStatus(installedStatus("Installed and activated", packageName, previousVersion, newVersion));
  } else if (manifest.type === "theme") {
    editor.reloadThemes();
    editor.setStatus(installedStatus("Installed theme", packageName, previousVersion, newVersion));
  } else if (manifest.type === "language") {
    await loadLanguagePack(correctTargetDir, manifest);
    editor.setStatus(installedStatus("Installed language pack", packageName, previousVersion, newVersion));
  } else if (manifest.type === "bundle") {
    await loadBundle(correctTargetDir, manifest);
    editor.setStatus(installedStatus("Installed bundle", packageName, previousVersion, newVersion));
  } else {
    editor.setStatus(installedStatus("Installed", packageName, previousVersion, newVersion));
  }
  return true;
}

/**
 * Install from a monorepo (URL with subpath fragment)
 *
 * Strategy:
 * 1. Clone the repo to a temp directory
 * 2. Detect package type from manifest
 * 3. Copy the subdirectory to the correct target location
 * 4. Store the original URL for reference
 */
async function installFromMonorepo(
  parsed: ParsedPackageUrl,
  packageName: string,
  version?: string
): Promise<boolean> {
  const tempDir = editor.pathJoin(editor.getTempDir(), `fresh-pkg-${hashString(parsed.repoUrl)}-${Date.now()}`);

  try {
    // Clone the full repo to temp
    editor.setStatus(`Cloning ${parsed.repoUrl}...`);
    const cloneArgs = ["clone"];
    if (!version || version === "latest") {
      cloneArgs.push("--depth", "1");
    }
    cloneArgs.push(`${parsed.repoUrl}`, `${tempDir}`);

    const cloneResult = await gitCommand(cloneArgs);
    if (cloneResult.exit_code !== 0) {
      const errorMsg = gitErrorMessage(cloneResult.stderr, "Clone failed");
      packageFailure(`Failed to clone repository: ${errorMsg}`);
      return false;
    }

    // Checkout specific version if requested
    if (version && version !== "latest") {
      await checkoutVersion(tempDir, version);
    }

    // Verify subpath exists
    const subpathDir = editor.pathJoin(tempDir, parsed.subpath!);
    if (!fsLocal.fileExists(subpathDir)) {
      packageFailure(`Subpath '${parsed.subpath}' not found in repository`);
      fsLocal.removePath(tempDir);
      return false;
    }

    // Validate package structure (validates against subpath dir)
    const validation = validatePackage(subpathDir, packageName);
    if (!validation.valid) {
      editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
      packageFailure(`Failed to install ${packageName}: ${validation.error}`);
      fsLocal.removePath(tempDir);
      return false;
    }

    const manifest = validation.manifest;

    // Use manifest name as the authoritative package name
    if (manifest?.name) packageName = manifest.name;

    // Determine correct target directory based on actual package type
    const actualType = manifest?.type || "plugin";
    const correctPackagesDir = actualType === "plugin" ? PACKAGES_DIR
                             : actualType === "theme" ? THEMES_PACKAGES_DIR
                             : actualType === "bundle" ? BUNDLES_PACKAGES_DIR
                             : LANGUAGES_PACKAGES_DIR;
    const correctTargetDir = editor.pathJoin(correctPackagesDir, packageName);

    // An existing installation is an upgrade, not a dead end. The
    // subdirectory in the clone is already fetched and validated, so
    // the old copy only goes once the replacement is known good.
    const previousVersion = fsLocal.fileExists(correctTargetDir)
      ? installedVersion(correctTargetDir)
      : null;

    // Ensure correct directory exists
    ensureDir(correctPackagesDir);

    // Store the original monorepo URL in a .fresh-source file. Written
    // into the clone before the swap so the marker lands with the files
    // it describes.
    const sourceInfo = {
      repository: parsed.repoUrl,
      subpath: parsed.subpath,
      installed_from: `${parsed.repoUrl}#${parsed.subpath}`,
      installed_at: new Date().toISOString()
    };
    await writeJsonFile(editor.pathJoin(subpathDir, ".fresh-source.json"), sourceInfo);

    if (previousVersion !== null) {
      // Unload before replacing: files deleted under a loaded plugin
      // leave the old copy running for the rest of the session.
      await unloadPluginsUnder(correctTargetDir);
    }

    // Copy subdirectory to correct target
    editor.setStatus(`Installing ${packageName} from ${parsed.subpath}...`);
    if (!swapInstalledDir(subpathDir, correctPackagesDir, packageName, false)) {
      packageFailure(`Failed to copy package from ${parsed.subpath}`);
      fsLocal.removePath(tempDir);
      return false;
    }

    const newVersion = manifest?.version ?? "unknown";

    // Dynamically load plugins, reload themes, load language packs, or load bundles
    if (manifest?.type === "plugin" && validation.entryPath) {
      // Update entry path to new location
      const newEntryPath = validation.entryPath.replace(subpathDir, correctTargetDir);
      await editor.loadPlugin(newEntryPath);
      editor.setStatus(installedStatus("Installed and activated", packageName, previousVersion, newVersion));
    } else if (manifest?.type === "theme") {
      editor.reloadThemes();
      editor.setStatus(installedStatus("Installed theme", packageName, previousVersion, newVersion));
    } else if (manifest?.type === "language") {
      await loadLanguagePack(correctTargetDir, manifest);
      editor.setStatus(installedStatus("Installed language pack", packageName, previousVersion, newVersion));
    } else if (manifest?.type === "bundle") {
      await loadBundle(correctTargetDir, manifest);
      editor.setStatus(installedStatus("Installed bundle", packageName, previousVersion, newVersion));
    } else {
      editor.setStatus(installedStatus("Installed", packageName, previousVersion, newVersion));
    }
    return true;
  } finally {
    // Cleanup temp directory
    fsLocal.removePath(tempDir);
  }
}

/**
 * Load a language pack (register grammar, language config, and LSP server)
 */
async function loadLanguagePack(packageDir: string, manifest: PackageManifest): Promise<void> {
  const langId = manifest.name;

  // Register grammar if present
  if (manifest.fresh?.grammar) {
    const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file);
    const extensions = manifest.fresh.grammar.extensions || [];
    editor.registerGrammar(langId, grammarPath, extensions);
  }

  // Register language config if present
  if (manifest.fresh?.language) {
    const lang = manifest.fresh.language;
    editor.registerLanguageConfig(langId, {
      commentPrefix: lang.commentPrefix ?? null,
      blockCommentStart: lang.blockCommentStart ?? null,
      blockCommentEnd: lang.blockCommentEnd ?? null,
      useTabs: lang.useTabs ?? null,
      tabSize: lang.tabSize ?? null,
      autoIndent: lang.autoIndent ?? null,
      showWhitespaceTabs: lang.showWhitespaceTabs ?? null,
      formatter: lang.formatter ? {
        command: lang.formatter.command,
        args: lang.formatter.args ?? [],
      } : null,
    });
  }

  // Register LSP server if present
  if (manifest.fresh?.lsp) {
    const lsp = manifest.fresh.lsp;
    editor.registerLspServer(langId, {
      command: lsp.command,
      args: lsp.args ?? [],
      autoStart: lsp.autoStart ?? null,
      initializationOptions: lsp.initializationOptions ?? null,
      processLimits: lsp.processLimits ?? null,
    });
  }

  // Apply changes
  await editor.reloadGrammars();
}

/**
 * Load a bundle package (register all languages and load all plugins)
 */
async function loadBundle(packageDir: string, manifest: PackageManifest): Promise<void> {
  const bundleName = manifest.name;
  editor.debug(`[pkg] Loading bundle: ${bundleName}`);

  // Load all languages from the bundle
  if (manifest.fresh?.languages) {
    for (const lang of manifest.fresh.languages) {
      const langId = lang.id;
      editor.debug(`[pkg] Loading bundle language: ${langId}`);

      // Register grammar if present
      if (lang.grammar) {
        const grammarPath = editor.pathJoin(packageDir, lang.grammar.file);
        const extensions = lang.grammar.extensions || [];
        editor.registerGrammar(langId, grammarPath, extensions);
      }

      // Register language config if present
      if (lang.language) {
        const langConfig = lang.language;
        editor.registerLanguageConfig(langId, {
          commentPrefix: langConfig.commentPrefix ?? null,
          blockCommentStart: langConfig.blockCommentStart ?? null,
          blockCommentEnd: langConfig.blockCommentEnd ?? null,
          useTabs: langConfig.useTabs ?? null,
          tabSize: langConfig.tabSize ?? null,
          autoIndent: langConfig.autoIndent ?? null,
          showWhitespaceTabs: langConfig.showWhitespaceTabs ?? null,
          formatter: langConfig.formatter ? {
            command: langConfig.formatter.command,
            args: langConfig.formatter.args ?? [],
          } : null,
        });
      }

      // Register LSP server if present
      if (lang.lsp) {
        const lsp = lang.lsp;
        editor.registerLspServer(langId, {
          command: lsp.command,
          args: lsp.args ?? [],
          autoStart: lsp.autoStart ?? null,
          initializationOptions: lsp.initializationOptions ?? null,
          processLimits: lsp.processLimits ?? null,
        });
      }
    }
  }

  // Load all plugins from the bundle
  if (manifest.fresh?.plugins) {
    for (const plugin of manifest.fresh.plugins) {
      let entryPath = editor.pathJoin(packageDir, plugin.entry);

      // Try .js fallback if .ts doesn't exist
      if (!fsLocal.fileExists(entryPath) && entryPath.endsWith(".ts")) {
        const jsPath = entryPath.replace(/\.ts$/, ".js");
        if (fsLocal.fileExists(jsPath)) {
          entryPath = jsPath;
        }
      }

      if (fsLocal.fileExists(entryPath)) {
        editor.debug(`[pkg] Loading bundle plugin: ${plugin.entry}`);
        await editor.loadPlugin(entryPath);
      } else {
        editor.warn(`[pkg] Bundle plugin not found: ${plugin.entry}`);
      }
    }
  }

  // Reload themes if bundle contains any (uses same format as theme-packs)
  if (manifest.fresh?.themes && manifest.fresh.themes.length > 0) {
    editor.debug(`[pkg] Bundle contains ${manifest.fresh.themes.length} theme(s), reloading themes`);
    editor.reloadThemes();
  }

  // Apply grammar changes
  await editor.reloadGrammars();
  editor.debug(`[pkg] Bundle loaded: ${bundleName}`);
}

/**
 * Checkout a specific version in a package directory
 */
async function checkoutVersion(pkgPath: string, version: string): Promise<boolean> {
  let target: string;

  if (version === "latest") {
    // Get latest tag
    const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]);
    const tags = tagsResult.stdout.split("\n").filter(t => t.trim());
    target = tags[0] || "HEAD";
  } else if (version.startsWith("^") || version.startsWith("~")) {
    // Semver matching - find best matching tag
    target = await findMatchingSemver(pkgPath, version);
  } else if (version.match(/^[0-9a-f]{7,40}$/)) {
    // Commit hash
    target = version;
  } else {
    // Exact version or branch
    target = version.startsWith("v") ? version : `v${version}`;
  }

  // Fetch if needed
  await gitCommand(["-C", `${pkgPath}`, "fetch", "--tags"]);

  // Checkout
  const result = await gitCommand(["-C", `${pkgPath}`, "checkout", target]);
  return result.exit_code === 0;
}

/**
 * Find best semver matching version
 */
async function findMatchingSemver(pkgPath: string, spec: string): Promise<string> {
  const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]);
  const tags = tagsResult.stdout.split("\n").filter(t => t.trim());

  // Simple semver matching (^ means compatible, ~ means patch only)
  const prefix = spec.startsWith("^") ? "^" : "~";
  const baseVersion = spec.slice(1);
  const [major, minor] = baseVersion.split(".").map(n => parseInt(n, 10));

  for (const tag of tags) {
    const version = tag.replace(/^v/, "");
    const [tagMajor, tagMinor] = version.split(".").map(n => parseInt(n, 10));

    if (prefix === "^") {
      // Compatible: same major
      if (tagMajor === major && !isNaN(tagMinor)) {
        return tag;
      }
    } else {
      // Patch: same major.minor
      if (tagMajor === major && tagMinor === minor) {
        return tag;
      }
    }
  }

  // Fallback to latest
  return tags[0] || "HEAD";
}

/**
 * Where an installed package can be re-fetched from, or `null` when
 * nothing recorded a source.
 *
 * The `.fresh-source.json` marker comes first because every install
 * path writes it; the git remote is the fallback for plain-repo
 * installs made before the marker existed.
 */
function updateSource(pkg: InstalledPackage): string | null {
  return pkg.installedFrom || pkg.source || null;
}

/**
 * Update a package by re-running its install from the source it came
 * from.
 *
 * This used to be `git -C <pkgdir> pull --ff-only`, which only means
 * anything for a plain-repo install, where the clone itself became the
 * installed directory. A monorepo install is a copy of a subdirectory
 * taken out of a throwaway clone and a local install is a copy of a
 * directory: neither has a `.git`, so the pull failed and those
 * packages could never be updated at all. Re-installing works for every
 * kind, and inherits the install path's guarantees — it validates the
 * new copy before touching the old one, unloads before replacing, and
 * reloads afterwards.
 */
async function updatePackage(pkg: InstalledPackage): Promise<boolean> {
  const source = updateSource(pkg);
  if (!source) {
    packageFailure(`Cannot update ${pkg.name}: no recorded install source`);
    return false;
  }

  editor.setStatus(`Updating ${pkg.name} from ${source}...`);
  return await installPackage(source, pkg.name);
}

/**
 * Remove a package
 */
async function removePackage(pkg: InstalledPackage): Promise<boolean> {
  editor.setStatus(`Removing ${pkg.name}...`);

  // Unload the plugin first (ignore errors - plugin might not be loaded)
  // Use listPlugins to find the correct runtime plugin name by matching path
  if (pkg.type === "plugin") {
    const loadedPlugins = await editor.listPlugins();
    const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path));
    if (plugin) {
      await editor.unloadPlugin(plugin.name).catch(() => {});
    }
  }

  // Remove package directory
  if (fsLocal.removePath(pkg.path)) {
    // Reload themes if we removed a theme so Select Theme list is updated
    if (pkg.type === "theme") {
      editor.reloadThemes();
    }
    editor.setStatus(`Removed ${pkg.name}`);
    return true;
  } else {
    packageFailure(`Failed to remove ${pkg.name}`);
    return false;
  }
}

/**
 * Update all packages
 */
async function updateAllPackages(): Promise<void> {
  const all = getAllInstalledPackages();

  if (all.length === 0) {
    editor.setStatus("No packages installed");
    return;
  }

  let updated = 0;
  let failed = 0;

  for (const pkg of all) {
    editor.setStatus(`Updating ${pkg.name} (${updated + failed + 1}/${all.length})...`);

    // One path for every kind of install — see `updatePackage`. A
    // re-install always replaces, so there is no "unchanged" outcome
    // to report any more.
    if (await updatePackage(pkg)) {
      updated++;
    } else {
      failed++;
    }
  }

  editor.setStatus(`Update complete: ${updated} updated, ${failed} failed`);
}

// =============================================================================
// Lockfile Operations
// =============================================================================

/**
 * Generate lockfile from current state
 */
async function generateLockfile(): Promise<void> {
  editor.setStatus("Generating lockfile...");

  const all = getAllInstalledPackages();

  const lockfile: Lockfile = {
    lockfile_version: 1,
    generated: new Date().toISOString(),
    packages: {}
  };

  for (const pkg of all) {
    // Get current commit
    const commitResult = await gitCommand(["-C", `${pkg.path}`, "rev-parse", "HEAD"]);
    const commit = commitResult.stdout.trim();

    lockfile.packages[pkg.name] = {
      source: pkg.source,
      commit,
      version: pkg.version
    };
  }

  if (await writeJsonFile(LOCKFILE_PATH, lockfile)) {
    editor.setStatus(`Lockfile generated with ${all.length} packages`);
  } else {
    editor.setStatus("Failed to write lockfile");
  }
}

/**
 * Install packages from lockfile
 */
async function installFromLockfile(): Promise<void> {
  const lockfile = readJsonFile<Lockfile>(LOCKFILE_PATH);
  if (!lockfile) {
    editor.setStatus("No lockfile found");
    return;
  }

  editor.setStatus("Installing from lockfile...");

  let installed = 0;
  let failed = 0;

  for (const [name, entry] of Object.entries(lockfile.packages)) {
    editor.setStatus(`Installing ${name} (${installed + failed + 1}/${Object.keys(lockfile.packages).length})...`);

    // Check if already installed
    const pluginPath = editor.pathJoin(PACKAGES_DIR, name);
    const themePath = editor.pathJoin(THEMES_PACKAGES_DIR, name);

    if (fsLocal.fileExists(pluginPath) || fsLocal.fileExists(themePath)) {
      // Already installed, just checkout the commit
      const path = fsLocal.fileExists(pluginPath) ? pluginPath : themePath;
      await gitCommand(["-C", `${path}`, "fetch"]);
      const result = await gitCommand(["-C", `${path}`, "checkout", entry.commit]);
      if (result.exit_code === 0) {
        installed++;
      } else {
        failed++;
      }
    } else {
      // Need to clone
      ensureDir(PACKAGES_DIR);
      const result = await gitCommand(["clone", `${entry.source}`, `${pluginPath}`]);

      if (result.exit_code === 0) {
        await gitCommand(["-C", `${pluginPath}`, "checkout", entry.commit]);
        installed++;
      } else {
        failed++;
      }
    }
  }

  editor.setStatus(`Lockfile install complete: ${installed} installed, ${failed} failed`);
}

// =============================================================================
// Package Manager UI (VSCode-style virtual buffer)
// =============================================================================

// UI State
interface PackageListItem {
  type: "installed" | "available";
  name: string;
  description: string;
  version: string;
  installed: boolean;
  updateAvailable: boolean;
  latestVersion?: string;
  author?: string;
  license?: string;
  repository?: string;
  stars?: number;
  downloads?: number;
  keywords?: string[];
  packageType: "plugin" | "theme" | "language" | "bundle";
  // For installed packages
  installedPackage?: InstalledPackage;
  // For available packages
  registryEntry?: RegistryEntry;
}

// Focus target types for Tab navigation
type FocusTarget =
  | { type: "filter"; index: number }  // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages, 5=Bundles
  | { type: "sync" }
  | { type: "search" }
  | { type: "list" }  // Package list (use arrows to navigate)
  | { type: "action"; index: number };  // Action buttons for selected package

interface PkgManagerState {
  isOpen: boolean;
  splitId: number | null;
  sourceBufferId: number | null;
  filter: "all" | "installed" | "plugins" | "themes" | "languages" | "bundles";
  searchQuery: string;
  /** Last failed action, shown in the detail panel. The status bar is
   * overwritten by any other code path, so a failure the user has to
   * act on gets its own place in this UI. */
  error: string | null;
  items: PackageListItem[];
  selectedIndex: number;
  focus: FocusTarget;  // What element has Tab focus
  isLoading: boolean;
  viewportHeight: number;
  // Buffer group fields
  groupId: number | null;
  panelBuffers: Record<string, number>;
  /** Widget panels owned by the pkg manager. The list panel hosts a
   * `List` widget (one row per package) so the host owns selection
   * highlight, scroll, click-to-select, mouse wheel, and PageUp/Down.
   * The footer panel hosts a `HintBar` widget (the Navigate / Tab /
   * Search / Esc cheat-sheet). Header and detail panels still use
   * `setPanelContent` since their UI shape spans cross-panel focus
   * (filter / sync / search / action buttons) which is a larger
   * redesign. */
  listPanel: WidgetPanel | null;
  footerPanel: WidgetPanel | null;
}

const pkgState: PkgManagerState = {
  isOpen: false,
  splitId: null,
  sourceBufferId: null,
  filter: "all",
  searchQuery: "",
  error: null,
  items: [],
  selectedIndex: 0,
  focus: { type: "list" },
  isLoading: false,
  viewportHeight: 24,
  groupId: null,
  panelBuffers: {},
  listPanel: null,
  footerPanel: null,
};

// Theme-aware color configuration
// Maps UI elements to theme keys with RGB fallbacks
interface ThemeColor {
  fg?: { theme?: string; rgb: [number, number, number] };
  bg?: { theme?: string; rgb: [number, number, number] };
}

const pkgTheme: Record<string, ThemeColor> = {
  // Headers and titles
  header: { fg: { theme: "syntax.keyword", rgb: [100, 180, 255] } },
  sectionTitle: { fg: { theme: "syntax.function", rgb: [180, 140, 80] } },

  // Package items
  installed: { fg: { theme: "syntax.string", rgb: [100, 200, 120] } },
  available: { fg: { theme: "editor.fg", rgb: [200, 200, 210] } },
  selected: {
    fg: { theme: "ui.menu_active_fg", rgb: [255, 255, 255] },
    bg: { theme: "ui.menu_active_bg", rgb: [50, 80, 120] }
  },

  // Descriptions and details
  description: { fg: { theme: "syntax.comment", rgb: [140, 140, 150] } },
  infoRow: { fg: { theme: "editor.fg", rgb: [180, 180, 190] } },
  infoLabel: { fg: { theme: "syntax.comment", rgb: [120, 120, 130] } },
  infoValue: { fg: { theme: "editor.fg", rgb: [200, 200, 210] } },

  // UI elements
  separator: { fg: { rgb: [60, 60, 65] } },
  divider: { fg: { rgb: [50, 50, 55] } },
  help: { fg: { theme: "syntax.comment", rgb: [100, 100, 110] } },
  emptyState: { fg: { theme: "syntax.comment", rgb: [120, 120, 130] } },

  // Filter buttons
  filterActive: {
    fg: { rgb: [255, 255, 255] },
    bg: { theme: "syntax.keyword", rgb: [60, 100, 160] }
  },
  filterInactive: {
    fg: { rgb: [160, 160, 170] },
  },
  filterFocused: {
    fg: { rgb: [255, 255, 255] },
    bg: { rgb: [80, 80, 90] }
  },

  // Action buttons
  button: {
    fg: { rgb: [180, 180, 190] },
  },
  buttonFocused: {
    fg: { rgb: [255, 255, 255] },
    bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }
  },

  // Search box - distinct input field appearance
  searchBox: {
    fg: { rgb: [200, 200, 210] },
    bg: { rgb: [40, 42, 48] }
  },
  searchBoxFocused: {
    fg: { rgb: [255, 255, 255] },
    bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }
  },

  // Status indicators
  statusOk: { fg: { rgb: [100, 200, 120] } },
  statusUpdate: { fg: { rgb: [220, 180, 80] } },
};

// Define pkg-manager mode with arrow key navigation
editor.defineMode(
  "pkg-manager",
  [
    ["Up", "pkg_nav_up"],
    ["Down", "pkg_nav_down"],
    ["Return", "pkg_activate"],
    ["Tab", "pkg_next_button"],
    ["S-Tab", "pkg_prev_button"],
    ["Escape", "pkg_back_or_close"],
    ["/", "pkg_search"],
  ],
  true // read-only
);

// Define pkg-detail mode for package details view
editor.defineMode(
  "pkg-detail",
  [
    ["Up", "pkg_scroll_up"],
    ["Down", "pkg_scroll_down"],
    ["Return", "pkg_activate"],
    ["Tab", "pkg_next_button"],
    ["S-Tab", "pkg_prev_button"],
    ["Escape", "pkg_back_or_close"],
  ],
  true // read-only
);

/**
 * Compare two dotted version strings numerically, component by
 * component: negative when `a` sorts before `b`, positive after, zero
 * when equal. A leading `v` is ignored and non-numeric components
 * compare as 0, which is enough to answer "is the registry's version
 * newer than the one on disk" without pulling in a semver parser.
 */
function compareVersions(a: string, b: string): number {
  const parts = (v: string) => v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
  const av = parts(a);
  const bv = parts(b);
  for (let i = 0; i < Math.max(av.length, bv.length); i++) {
    const diff = (av[i] ?? 0) - (bv[i] ?? 0);
    if (diff !== 0) return diff;
  }
  return 0;
}

/**
 * Build package list from installed and registry data
 */
function buildPackageList(): PackageListItem[] {
  const items: PackageListItem[] = [];

  // Registry entries, keyed by package name, loaded up front so the
  // installed rows below can consult them too — an installed package
  // the registry knows about carries its `latest_version`, which is
  // the one thing that lets `updateAvailable` be answered honestly.
  const registrySynced = isRegistrySynced();
  const registryEntries = new Map<string, RegistryEntry>();
  const pluginRegistry = registrySynced ? loadRegistry("plugins") : null;
  const themeRegistry = registrySynced ? loadRegistry("themes") : null;
  const languageRegistry = registrySynced ? loadRegistry("languages") : null;
  for (const registry of [pluginRegistry, themeRegistry, languageRegistry]) {
    if (!registry) continue;
    for (const [name, entry] of Object.entries(registry.packages)) {
      registryEntries.set(name, entry);
    }
  }

  // Get installed packages
  const installedPlugins = getInstalledPackages("plugin");
  const installedThemes = getInstalledPackages("theme");
  const installedLanguages = getInstalledPackages("language");
  const installedBundles = getInstalledPackages("bundle");
  const installedMap = new Map<string, InstalledPackage>();

  for (const pkg of [...installedPlugins, ...installedThemes, ...installedLanguages, ...installedBundles]) {
    installedMap.set(pkg.name, pkg);
    const entry = registryEntries.get(pkg.name);
    // An update is "available" when the registry names a version newer
    // than the one on disk. Packages the registry doesn't list have
    // nothing to compare against — they still get an Update action,
    // which re-installs from their recorded source.
    const latestVersion = entry?.latest_version;
    const updateAvailable = latestVersion !== undefined
      && pkg.version !== "unknown"
      && compareVersions(latestVersion, pkg.version) > 0;
    items.push({
      type: "installed",
      name: pkg.name,
      description: pkg.manifest?.description || entry?.description || "No description",
      version: pkg.version,
      installed: true,
      updateAvailable,
      latestVersion,
      author: pkg.manifest?.author || entry?.author,
      license: pkg.manifest?.license || entry?.license,
      repository: pkg.source,
      keywords: pkg.manifest?.keywords || entry?.keywords,
      packageType: pkg.type,
      installedPackage: pkg,
      registryEntry: entry,
    });
  }

  // Get available packages from registry
  if (pluginRegistry && themeRegistry && languageRegistry) {
    for (const [name, entry] of Object.entries(pluginRegistry.packages)) {
      if (!installedMap.has(name)) {
        items.push({
          type: "available",
          name,
          description: entry.description || "No description",
          version: entry.latest_version || "latest",
          installed: false,
          updateAvailable: false,
          latestVersion: entry.latest_version,
          author: entry.author,
          license: entry.license,
          repository: entry.repository,
          stars: entry.stars,
          downloads: entry.downloads,
          keywords: entry.keywords,
          packageType: "plugin",
          registryEntry: entry,
        });
      }
    }

    for (const [name, entry] of Object.entries(themeRegistry.packages)) {
      if (!installedMap.has(name)) {
        items.push({
          type: "available",
          name,
          description: entry.description || "No description",
          version: entry.latest_version || "latest",
          installed: false,
          updateAvailable: false,
          latestVersion: entry.latest_version,
          author: entry.author,
          license: entry.license,
          repository: entry.repository,
          stars: entry.stars,
          downloads: entry.downloads,
          keywords: entry.keywords,
          packageType: "theme",
          registryEntry: entry,
        });
      }
    }

    // Add language packages from registry
    for (const [name, entry] of Object.entries(languageRegistry.packages)) {
      if (!installedMap.has(name)) {
        items.push({
          type: "available",
          name,
          description: entry.description || "No description",
          version: entry.latest_version || "latest",
          installed: false,
          updateAvailable: false,
          latestVersion: entry.latest_version,
          author: entry.author,
          license: entry.license,
          repository: entry.repository,
          stars: entry.stars,
          downloads: entry.downloads,
          keywords: entry.keywords,
          packageType: "language",
          registryEntry: entry,
        });
      }
    }
  }

  return items;
}

/**
 * Filter items based on current filter and search query
 */
function getFilteredItems(): PackageListItem[] {
  let items = pkgState.items;

  // Apply filter
  switch (pkgState.filter) {
    case "installed":
      items = items.filter(i => i.installed);
      break;
    case "plugins":
      items = items.filter(i => i.packageType === "plugin");
      break;
    case "themes":
      items = items.filter(i => i.packageType === "theme");
      break;
    case "languages":
      items = items.filter(i => i.packageType === "language");
      break;
    case "bundles":
      items = items.filter(i => i.packageType === "bundle");
      break;
  }

  // Apply search (case insensitive)
  if (pkgState.searchQuery) {
    const query = pkgState.searchQuery.toLowerCase();
    items = items.filter(i =>
      i.name.toLowerCase().includes(query) ||
      (i.description && i.description.toLowerCase().includes(query)) ||
      (i.keywords && i.keywords.some(k => k.toLowerCase().includes(query)))
    );
  }

  // Sort: installed first, then by name
  items.sort((a, b) => {
    if (a.installed !== b.installed) {
      return a.installed ? -1 : 1;
    }
    return a.name.localeCompare(b.name);
  });

  return items;
}

/**
 * Format number with K/M suffix
 */
function formatNumber(n: number | undefined): string {
  if (n === undefined) return "";
  if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
  if (n >= 1000) return (n / 1000).toFixed(1) + "k";
  return n.toString();
}

// Layout constants
const TOTAL_WIDTH = 88;
const LIST_WIDTH = 36;
function DETAIL_WIDTH(): number { return TOTAL_WIDTH - LIST_WIDTH - 3; }

/**
 * Helper to check if a button is focused
 */
function isButtonFocused(type: FocusTarget["type"], index?: number): boolean {
  if (pkgState.focus.type !== type) return false;
  if (index !== undefined && "index" in pkgState.focus) {
    return pkgState.focus.index === index;
  }
  return true;
}

/**
 * Get action buttons for the selected package
 */
function getActionButtons(): string[] {
  const items = getFilteredItems();
  if (items.length === 0 || pkgState.selectedIndex >= items.length) return [];
  const item = items[pkgState.selectedIndex];

  if (item.installed) {
    // "Update" is offered whenever we know where the package came
    // from, not only when a newer version has been detected: an
    // update is a re-install from the recorded source, which is also
    // what the old "Reinstall" button did, so the two collapse into
    // one action. Gating it on `updateAvailable` is what used to make
    // it unreachable — that flag is only knowable for packages the
    // registry lists a version for.
    const canUpdate = item.installedPackage !== undefined
      && updateSource(item.installedPackage) !== null;
    return canUpdate ? ["Update", "Uninstall"] : ["Uninstall"];
  } else {
    return ["Install"];
  }
}

/**
 * Word-wrap text to fit within a given width
 */
function wrapText(text: string, maxWidth: number): string[] {
  const words = text.split(/\s+/);
  const lines: string[] = [];
  let currentLine = "";

  for (const word of words) {
    if (currentLine.length + word.length + 1 <= maxWidth) {
      currentLine += (currentLine ? " " : "") + word;
    } else {
      if (currentLine) lines.push(currentLine);
      currentLine = word.length > maxWidth ? word.slice(0, maxWidth - 1) + "…" : word;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines.length > 0 ? lines : [""];
}

/**
 * Build virtual buffer entries for the package manager (split-view layout)
 */
function utf8ByteLength(str: string): number {
  let bytes = 0;
  for (let i = 0; i < str.length; i++) {
    const code = str.charCodeAt(i);
    if (code < 0x80) {
      bytes += 1;
    } else if (code < 0x800) {
      bytes += 2;
    } else if (code >= 0xD800 && code <= 0xDBFF) {
      // Surrogate pair = 4 bytes, skip low surrogate
      bytes += 4;
      i++;
    } else {
      bytes += 3;
    }
  }
  return bytes;
}

function buildPkgHeaderEntries(): TextPropertyEntry[] {
  const entries: TextPropertyEntry[] = [];
  entries.push({ text: " Packages\n", properties: { type: "header" } });
  // Search bar
  const searchFocused = pkgState.focus.type === "search";
  const searchLeft = searchFocused ? "[" : " ";
  const searchRight = searchFocused ? "]" : " ";
  const searchVal = pkgState.searchQuery || "";
  entries.push({ text: ` Search: ${searchLeft}${searchVal.padEnd(30)}${searchRight}\n`, properties: { type: "search-input", focused: searchFocused } });
  // Filter bar
  const filters = ["All", "Installed", "Plugins", "Themes", "Languages", "Bundles"];
  let filterLine = " ";
  for (let i = 0; i < filters.length; i++) {
    const isActive = pkgState.filter === filters[i].toLowerCase();
    const isFocused = pkgState.focus.type === "filter" && pkgState.focus.index === i;
    const lb = isFocused ? "[" : " ";
    const rb = isFocused ? "]" : " ";
    filterLine += `${lb} ${filters[i]} ${rb} `;
  }
  const syncFocused = pkgState.focus.type === "sync";
  const sl = syncFocused ? "[" : " ";
  const sr = syncFocused ? "]" : " ";
  filterLine += `  ${sl} Sync ${sr}`;
  entries.push({ text: filterLine + "\n", properties: { type: "filter-bar" } });
  return entries;
}

/** Build the per-row item entries for the package List widget.
 * The list iterates "installed first, then available" to match the
 * existing buffer-render order, so the row's index in this array
 * matches `pkgState.selectedIndex`. The leading `▸` arrow that the
 * old hand-rolled renderer used on the focused row is gone — the
 * List widget paints the selection highlight via `ui.menu_active_bg`
 * which carries the same "this row is selected" signal. Section
 * headers (`INSTALLED (n)` / `AVAILABLE (n)`) are kept as
 * non-selectable rows; selecting them would be a no-op so the list
 * navigation skips them implicitly via the index math. */
interface PkgListRow {
  entry: TextPropertyEntry;
  /** Stable widget-key for the List item. Empty string for non-
   * selectable header rows (the List still tracks them in
   * itemKeys but they don't carry useful identity). */
  key: string;
  /** Index within `getFilteredItems()` if this row is a real
   * package, else -1. The widget's `selectedIndex` is reported
   * back via `widget_event "select"` and we use it to look up
   * the matching package via this map. */
  itemIndex: number;
}

function buildPkgListRows(): PkgListRow[] {
  const items = getFilteredItems();
  const installedItems = items.filter((i) => i.installed);
  const availableItems = items.filter((i) => !i.installed);
  const rows: PkgListRow[] = [];

  if (installedItems.length > 0) {
    rows.push({
      entry: {
        text: `INSTALLED (${installedItems.length})`,
        properties: { type: "section-title" },
      },
      key: "section.installed",
      itemIndex: -1,
    });
    let idx = 0;
    for (const item of installedItems) {
      const status = item.updateAvailable ? "↑" : "✓";
      const ver =
        item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version;
      const nameW = Math.max(8, LIST_WIDTH - 16);
      const name =
        item.name.length > nameW ? item.name.slice(0, nameW - 1) + "…" : item.name;
      rows.push({
        entry: {
          text: `  ${name.padEnd(nameW)} ${ver.padEnd(7)} ${status}`,
          properties: { type: "package-row", installed: true },
        },
        key: `pkg.${item.name}`,
        itemIndex: idx,
      });
      idx++;
    }
  }

  if (availableItems.length > 0) {
    if (rows.length > 0) {
      rows.push({
        entry: { text: "", properties: { type: "blank" } },
        key: "blank",
        itemIndex: -1,
      });
    }
    rows.push({
      entry: {
        text: `AVAILABLE (${availableItems.length})`,
        properties: { type: "section-title" },
      },
      key: "section.available",
      itemIndex: -1,
    });
    let idx = installedItems.length;
    for (const item of availableItems) {
      const typeTag =
        item.packageType === "theme"
          ? "T"
          : item.packageType === "language"
            ? "L"
            : item.packageType === "bundle"
              ? "B"
              : "P";
      const availNameW = Math.max(8, LIST_WIDTH - 10);
      const name =
        item.name.length > availNameW
          ? item.name.slice(0, availNameW - 1) + "…"
          : item.name;
      rows.push({
        entry: {
          text: `  ${name.padEnd(availNameW)} [${typeTag}]`,
          properties: { type: "package-row", installed: false },
        },
        key: `pkg.${item.name}`,
        itemIndex: idx,
      });
      idx++;
    }
  }

  if (items.length === 0) {
    rows.push({
      entry: {
        text: pkgState.isLoading ? "Loading..." : "No packages found",
        properties: { type: "empty-state" },
      },
      key: "empty",
      itemIndex: -1,
    });
  }

  return rows;
}

/** Cache the row→item-index lookup so `widget_event "select"` can
 * map the host's reported selection index back to a real package
 * index in O(1) without rebuilding the row array. Refreshed by
 * `renderPkgList` every time the list is re-emitted. */
let pkgListRowCache: PkgListRow[] = [];

function selectedRowToItemIndex(rowIndex: number): number {
  const row = pkgListRowCache[rowIndex];
  return row ? row.itemIndex : -1;
}

function itemIndexToRow(itemIndex: number): number {
  for (let i = 0; i < pkgListRowCache.length; i++) {
    if (pkgListRowCache[i].itemIndex === itemIndex) return i;
  }
  return -1;
}

function renderPkgList(): void {
  if (pkgState.listPanel === null) return;
  const rows = buildPkgListRows();
  pkgListRowCache = rows;
  // Map the plugin's item-index selection to the row-index the List
  // widget understands. -1 falls back to the first selectable row.
  let rowSel = itemIndexToRow(pkgState.selectedIndex);
  if (rowSel < 0) {
    for (let i = 0; i < rows.length; i++) {
      if (rows[i].itemIndex >= 0) {
        rowSel = i;
        break;
      }
    }
  }
  pkgState.listPanel.set(
    list({
      items: rows.map((r) => r.entry),
      itemKeys: rows.map((r) => r.key),
      selectedIndex: rowSel,
      visibleRows: Math.max(1, rows.length),
      key: "pkg-list",
    }),
  );
  // The spec's `selectedIndex` only takes effect on the first mount:
  // `updateWidgetPanel` preserves the host's instance state by design,
  // so every later render leaves the widget's selection wherever it
  // was. State it explicitly instead. Without this, a panel that
  // mounted before the registry finished loading — an empty list, so
  // selection -1 — kept that -1 once the packages arrived, and the
  // first two arrow presses went into catching the widget up rather
  // than moving the selection.
  if (rowSel >= 0) {
    pkgState.listPanel.setSelectedIndex("pkg-list", rowSel);
  }
}

function buildPkgDetailEntries(): TextPropertyEntry[] {
  const items = getFilteredItems();
  const selectedItem = items.length > 0 && pkgState.selectedIndex < items.length
    ? items[pkgState.selectedIndex] : null;
  const entries: TextPropertyEntry[] = [];

  if (selectedItem) {
    entries.push({ text: selectedItem.name + "\n", properties: { type: "detail-title" } });
    entries.push({ text: "─".repeat(Math.min(selectedItem.name.length + 2, 50)) + "\n", properties: { type: "detail-sep" } });
    // The kind is on the row for available packages (the `[P]`/`[T]`
    // tag) but nowhere for installed ones, so state it here — it is
    // read straight off the manifest, which every installed package
    // has whether or not a registry lists it.
    let metaLine = `${selectedItem.packageType} • v${selectedItem.version}`;
    if (selectedItem.updateAvailable && selectedItem.latestVersion) {
      metaLine += ` → v${selectedItem.latestVersion}`;
    }
    if (selectedItem.author) metaLine += ` • ${selectedItem.author}`;
    if (selectedItem.license) metaLine += ` • ${selectedItem.license}`;
    entries.push({ text: metaLine + "\n", properties: { type: "detail-meta" } });
    entries.push({ text: "\n", properties: { type: "blank" } });
    const descText = selectedItem.description || "No description available";
    const descLines = wrapText(descText, 50);
    for (const line of descLines) {
      entries.push({ text: line + "\n", properties: { type: "detail-desc" } });
    }
    entries.push({ text: "\n", properties: { type: "blank" } });
    if (selectedItem.keywords && selectedItem.keywords.length > 0) {
      entries.push({ text: `Tags: ${selectedItem.keywords.slice(0, 4).join(", ")}\n`, properties: { type: "detail-tags" } });
      entries.push({ text: "\n", properties: { type: "blank" } });
    }
    if (selectedItem.repository) {
      let displayUrl = selectedItem.repository.replace(/^https?:\/\//, "").replace(/\.git$/, "");
      if (displayUrl.length > 50) displayUrl = displayUrl.slice(0, 47) + "...";
      entries.push({ text: displayUrl + "\n", properties: { type: "detail-url" } });
      entries.push({ text: "\n", properties: { type: "blank" } });
    }
    if (pkgState.error) {
      for (const line of wrapText(pkgState.error, 50)) {
        entries.push({
          text: line + "\n",
          properties: { type: "detail-error" },
          style: { fg: "diagnostics.error" },
        });
      }
      entries.push({ text: "\n", properties: { type: "blank" } });
    }
    const actions = getActionButtons();
    for (let i = 0; i < actions.length; i++) {
      const focused = pkgState.focus.type === "action" && pkgState.focus.index === i;
      const lb = focused ? "[" : " ";
      const rb = focused ? "]" : " ";
      entries.push({ text: `${lb} ${actions[i]} ${rb}\n`, properties: { type: "action-btn", focused, btnIndex: i } });
    }
  } else {
    entries.push({ text: "Select a package\nto view details\n", properties: { type: "empty-state" } });
  }

  return entries;
}

function renderPkgFooter(): void {
  if (pkgState.footerPanel === null) return;
  let actionLabel = "Select";
  if (pkgState.focus.type === "action") actionLabel = "Activate";
  else if (pkgState.focus.type === "filter") actionLabel = "Filter";
  else if (pkgState.focus.type === "sync") actionLabel = "Sync";
  else if (pkgState.focus.type === "search") actionLabel = "Search";
  // The hint string follows the existing `parseHintString` shape:
  // tokens separated by two spaces, each token is `<keys>:<label>`
  // or `<keys> <label>`. The host's HintBar styles the keys portion
  // with `ui.help_key_fg`.
  const hintString = `↑↓:Navigate  Tab:Next  /:Search  Enter:${actionLabel}  Esc:Close`;
  pkgState.footerPanel.set(hintBar(parseHintString(hintString)));
}

function updatePkgManagerView(): void {
  if (pkgState.groupId === null) return;
  // The item set changes under the selection — the background registry
  // sync lands, a filter narrows it, a package is uninstalled — so pin
  // it back into range before anything renders against it.
  const itemCount = getFilteredItems().length;
  pkgState.selectedIndex = itemCount === 0
    ? 0
    : Math.min(pkgState.selectedIndex, itemCount - 1);
  // Header + detail still flow through the legacy `setPanelContent`
  // path — their UI shape (filter/sync/search inputs in the header,
  // action buttons in the detail) spans cross-panel focus, which
  // is a larger redesign best done together with the §4.1
  // Compositor work. List and footer migrate independently because
  // they don't participate in cross-panel focus.
  editor.setPanelContent(pkgState.groupId, "header", buildPkgHeaderEntries());
  renderPkgList();
  editor.setPanelContent(pkgState.groupId, "detail", buildPkgDetailEntries());
  renderPkgFooter();
}

/**
 * Open the package manager
 */
async function openPackageManager(): Promise<void> {
  if (pkgState.isOpen) {
    // Adopt the group we already own and bring it to the front, rather
    // than stacking a second one. This used to key off `pkgState
    // .bufferId`, which nothing ever assigned (the buffer-group
    // migration left it null), so re-running the command while the
    // manager was open did nothing at all.
    const listBufferId = pkgState.panelBuffers["list"];
    if (typeof listBufferId === "number" && bufferExists(listBufferId)) {
      editor.showBuffer(listBufferId);
      return;
    }
    // The group is gone without us hearing about it. Drop the dead
    // handles and open a new one instead of returning to a UI that no
    // longer exists.
    unmountPkgPanels();
    resetPkgManagerState();
  }

  // Store current buffer
  pkgState.sourceBufferId = editor.getActiveBufferId();
  pkgState.splitId = editor.getActiveSplitId();

  // Reset state
  pkgState.filter = "all";
  pkgState.searchQuery = "";
  pkgState.selectedIndex = 0;
  pkgState.focus = { type: "list" };
  pkgState.error = null;

  // Build package list immediately with installed packages and cached registry
  pkgState.items = buildPackageList();
  // With nothing installed and no cached registry the first paint has
  // no rows at all. Say so, rather than claiming "No packages found"
  // while the background sync below is still fetching them.
  pkgState.isLoading = !isRegistrySynced();

  // Create buffer group with layout:
  // vertical: [header(fixed 4), horizontal: [list, detail], footer(fixed 1)]
  const layout = JSON.stringify({
    type: "split",
    direction: "v",
    ratio: 0.1,
    first: { type: "fixed", id: "header", height: 3 },
    // ^ 3 rows: title, search, filter bar
    second: {
      type: "split",
      direction: "v",
      ratio: 0.9,
      first: {
        type: "split",
        direction: "h",
        ratio: 0.4,
        first: { type: "scrollable", id: "list" },
        second: { type: "scrollable", id: "detail" },
      },
      second: { type: "fixed", id: "footer", height: 1 },
    },
  });

  const groupResult = await editor.createBufferGroup("*Packages*", "pkg-manager", layout);
  pkgState.groupId = groupResult.groupId;
  pkgState.panelBuffers = groupResult.panels;
  pkgState.isOpen = true;

  // Mount widget panels for the list and footer. Header / detail
  // still use `setPanelContent` (see `updatePkgManagerView`).
  const listBufferId = pkgState.panelBuffers["list"];
  if (typeof listBufferId === "number") {
    pkgState.listPanel = new WidgetPanel(listBufferId);
  }
  const footerBufferId = pkgState.panelBuffers["footer"];
  if (typeof footerBufferId === "number") {
    pkgState.footerPanel = new WidgetPanel(footerBufferId);
  }

  // Set initial content for all panels
  updatePkgManagerView();

  // Sync registry in background and update view when done
  // User can still interact with installed packages during sync
  syncRegistry()
    .then(() => {
      if (pkgState.isOpen) {
        pkgState.isLoading = false;
        pkgState.items = buildPackageList();
        updatePkgManagerView();
      }
    })
    // Detached, so it needs its own catch: an unhandled rejection here
    // takes down the plugin runtime, and the browser is perfectly
    // usable for installed packages without the registry.
    .catch((e) => {
      editor.warn(`[pkg] Background registry sync failed: ${e}`);
      if (pkgState.isOpen) {
        pkgState.isLoading = false;
        updatePkgManagerView();
      }
    });
}

/** Whether `bufferId` is still a live buffer. */
function bufferExists(bufferId: number): boolean {
  return editor.listBuffers().some((b) => b.id === bufferId);
}

/**
 * Unmount the widget panels the manager owns.
 *
 * The host keys panels by (plugin, panel id) and is never told that a
 * panel's buffer went away, so a panel that is merely dropped on the
 * plugin side stays registered against a dead buffer for the rest of
 * the session. Closing the buffer group is not an unmount; only this
 * is.
 */
function unmountPkgPanels(): void {
  pkgState.listPanel?.unmount();
  pkgState.footerPanel?.unmount();
  pkgState.listPanel = null;
  pkgState.footerPanel = null;
  pkgListRowCache = [];
}

/** Drop every handle into a package-manager UI that no longer exists. */
function resetPkgManagerState(): void {
  pkgState.isOpen = false;
  pkgState.groupId = null;
  pkgState.panelBuffers = {};
  pkgState.splitId = null;
  pkgState.sourceBufferId = null;
  pkgState.error = null;
}

/**
 * Close the package manager
 */
function closePackageManager(): void {
  if (!pkgState.isOpen) return;

  const groupId = pkgState.groupId;
  const sourceBufferId = pkgState.sourceBufferId;
  const splitId = pkgState.splitId;

  // Unmount before the buffers go, and clear the state before the
  // close fires `buffer_closed` for each panel — by then there is
  // nothing left for `pkg_buffer_closed` to tear down, so the two
  // teardown routes can't trip over each other.
  unmountPkgPanels();
  resetPkgManagerState();

  if (groupId !== null) {
    editor.closeBufferGroup(groupId);
  }

  // Restore previous buffer if possible
  if (sourceBufferId !== null && splitId !== null) {
    editor.showBuffer(sourceBufferId);
  }
}

/**
 * Tear down when a panel buffer is closed by the editor rather than by
 * `pkg_back_or_close`: the group tab's ×, File → Close Buffer, or a
 * split going away.
 *
 * Without this the state stays "open" pointing at dead buffer ids, and
 * because the open path then takes its adopt branch, `Package:
 * Packages` never opens again for the rest of the session.
 */
function pkg_buffer_closed(data: { buffer_id: number }): void {
  if (!pkgState.isOpen) return;
  const isOurs = Object.values(pkgState.panelBuffers).some(
    (id) => id === data.buffer_id,
  );
  if (!isOurs) return;
  unmountPkgPanels();
  resetPkgManagerState();
}
registerHandler("pkg_buffer_closed", pkg_buffer_closed);
editor.on("buffer_closed", "pkg_buffer_closed");

/**
 * Get all focusable elements in order for Tab navigation
 */
function getFocusOrder(): FocusTarget[] {
  const order: FocusTarget[] = [
    { type: "search" },
    { type: "filter", index: 0 },  // All
    { type: "filter", index: 1 },  // Installed
    { type: "filter", index: 2 },  // Plugins
    { type: "filter", index: 3 },  // Themes
    { type: "filter", index: 4 },  // Languages
    { type: "filter", index: 5 },  // Bundles
    { type: "sync" },
    { type: "list" },
  ];

  // Add action buttons for selected package
  const actions = getActionButtons();
  for (let i = 0; i < actions.length; i++) {
    order.push({ type: "action", index: i });
  }

  return order;
}

/**
 * Find current focus index in the focus order
 */
function getCurrentFocusIndex(): number {
  const order = getFocusOrder();
  for (let i = 0; i < order.length; i++) {
    const target = order[i];
    if (target.type === pkgState.focus.type) {
      if ("index" in target && "index" in pkgState.focus) {
        if (target.index === pkgState.focus.index) return i;
      } else if (!("index" in target) && !("index" in pkgState.focus)) {
        return i;
      }
    }
  }
  return 6; // Default to list
}

// Navigation commands

/**
 * Move the list selection by `delta` packages.
 *
 * The plugin owns the selection and pushes it to the List widget
 * (`renderPkgList`), rather than forwarding the arrow key and adopting
 * whichever row the host lands on. That indirection is what made Down
 * look dead: the widget's rows include non-selectable section headers
 * and a spacer, and its instance state survives every re-render, so a
 * key press could move the widget's highlight without moving
 * `pkgState.selectedIndex` at all. Counting in packages makes one
 * press move the selection by exactly one package, every time.
 */
function moveSelection(delta: number): void {
  pkgState.focus = { type: "list" };
  const items = getFilteredItems();
  if (items.length === 0) return;
  const next = Math.min(
    items.length - 1,
    Math.max(0, pkgState.selectedIndex + delta),
  );
  if (next === pkgState.selectedIndex) return;
  pkgState.selectedIndex = next;
  // The error belongs to the package it happened on.
  pkgState.error = null;
  updatePkgManagerView();
}

function pkg_nav_up(): void {
  if (!pkgState.isOpen) return;
  moveSelection(-1);
}
registerHandler("pkg_nav_up", pkg_nav_up);

function pkg_nav_down(): void {
  if (!pkgState.isOpen) return;
  moveSelection(1);
}
registerHandler("pkg_nav_down", pkg_nav_down);

editor.on("widget_event", (data) => {
  if (
    pkgState.listPanel === null ||
    data.panel_id !== pkgState.listPanel.id()
  ) {
    return;
  }
  if (data.event_type === "select") {
    const rowIdx =
      typeof data.payload?.index === "number" ? data.payload.index : -1;
    if (rowIdx < 0) return;
    const itemIdx = selectedRowToItemIndex(rowIdx);
    if (itemIdx < 0) {
      // A click landed on a section header or the blank spacer between
      // sections. Those aren't packages, so put the highlight back on
      // the selected one instead of leaving it parked on a row the
      // detail panel has nothing to say about.
      const rowSel = itemIndexToRow(pkgState.selectedIndex);
      if (rowSel >= 0) {
        pkgState.listPanel?.setSelectedIndex("pkg-list", rowSel);
      }
      return;
    }
    if (itemIdx === pkgState.selectedIndex) return;
    pkgState.selectedIndex = itemIdx;
    pkgState.focus = { type: "list" };
    // The error belongs to the package it happened on.
    pkgState.error = null;
    // Re-render header/detail to reflect the new selection;
    // the list itself is already updated by the host (we don't
    // need to call renderPkgList again).
    if (pkgState.groupId !== null) {
      editor.setPanelContent(pkgState.groupId, "header", buildPkgHeaderEntries());
      editor.setPanelContent(pkgState.groupId, "detail", buildPkgDetailEntries());
    }
    renderPkgFooter();
    return;
  }
  if (data.event_type === "activate") {
    void pkg_activate();
    return;
  }
});

function pkg_next_button() : void {
  if (!pkgState.isOpen) return;

  const order = getFocusOrder();
  const currentIdx = getCurrentFocusIndex();
  const nextIdx = (currentIdx + 1) % order.length;
  pkgState.focus = order[nextIdx];
  updatePkgManagerView();
}
registerHandler("pkg_next_button", pkg_next_button);

function pkg_prev_button() : void {
  if (!pkgState.isOpen) return;

  const order = getFocusOrder();
  const currentIdx = getCurrentFocusIndex();
  const prevIdx = (currentIdx - 1 + order.length) % order.length;
  pkgState.focus = order[prevIdx];
  updatePkgManagerView();
}
registerHandler("pkg_prev_button", pkg_prev_button);

/**
 * Run one of the browser's package actions, refreshing the list
 * afterwards and keeping any failure on screen in the detail panel.
 * The action's own progress messages still go to the status bar.
 */
async function runPackageAction(
  run: () => Promise<boolean>,
): Promise<boolean> {
  pkgState.error = null;
  lastPackageError = null;
  const selectedName = getFilteredItems()[pkgState.selectedIndex]?.name;
  const ok = await run();
  if (!ok) {
    pkgState.error = lastPackageError ?? "Action failed";
  }
  pkgState.items = buildPackageList();
  // Keep the selection on the same package. The list is ordered
  // installed-first, so installing one moves it up past every
  // available entry and a positional index would land on something
  // else. A package that is gone (uninstalled) leaves the index where
  // it was, which `updatePkgManagerView` clamps into range.
  if (selectedName !== undefined) {
    const idx = getFilteredItems().findIndex((i) => i.name === selectedName);
    if (idx >= 0) {
      pkgState.selectedIndex = idx;
    }
  }
  return ok;
}

/**
 * Re-read what is installed and repaint, if the browser is open.
 *
 * Installs and removals started from outside the browser's own action
 * buttons — `Package: Install from URL`, the registry finder, the
 * Update / Remove finders — otherwise leave it showing the package set
 * from when it was opened, so a package just installed from a URL
 * doesn't appear until the browser is closed and reopened.
 */
function refreshPackageManagerIfOpen(): void {
  if (!pkgState.isOpen) return;
  pkgState.items = buildPackageList();
  updatePkgManagerView();
}

async function pkg_activate() : Promise<void> {
  if (!pkgState.isOpen) return;

  const focus = pkgState.focus;

  // Handle filter button activation
  if (focus.type === "filter") {
    const filters = ["all", "installed", "plugins", "themes", "languages", "bundles"] as const;
    pkgState.filter = filters[focus.index];
    pkgState.selectedIndex = 0;
    pkgState.error = null;
    pkgState.items = buildPackageList();
    updatePkgManagerView();
    return;
  }

  // Handle sync button
  if (focus.type === "sync") {
    await syncRegistry();
    pkgState.items = buildPackageList();
    updatePkgManagerView();
    return;
  }

  // Handle search button - open search prompt with current query
  if (focus.type === "search") {
    pkg_search();
    return;
  }

  // Handle list selection - move focus to action buttons
  if (focus.type === "list") {
    const items = getFilteredItems();
    if (items.length === 0) {
      if (!isRegistrySynced()) {
        await syncRegistry();
        pkgState.items = buildPackageList();
        updatePkgManagerView();
      }
      return;
    }
    // Move focus to action button
    pkgState.focus = { type: "action", index: 0 };
    updatePkgManagerView();
    return;
  }

  // Handle action button activation
  if (focus.type === "action") {
    const items = getFilteredItems();
    if (items.length === 0 || pkgState.selectedIndex >= items.length) return;

    const item = items[pkgState.selectedIndex];
    const actions = getActionButtons();
    const actionName = actions[focus.index];

    const installedPackage = item.installedPackage;
    if (actionName === "Update" && installedPackage) {
      await runPackageAction(() => updatePackage(installedPackage));
      updatePkgManagerView();
    } else if (actionName === "Uninstall" && installedPackage) {
      await runPackageAction(() => removePackage(installedPackage));
      const newItems = getFilteredItems();
      pkgState.selectedIndex = Math.min(pkgState.selectedIndex, Math.max(0, newItems.length - 1));
      pkgState.focus = { type: "list" };
      updatePkgManagerView();
    } else if (actionName === "Install") {
      const installUrl = item.registryEntry?.repository ?? item.repository;
      if (installUrl) {
        await runPackageAction(() =>
          installPackage(installUrl, item.name, item.packageType)
        );
      } else {
        pkgState.error = `Cannot install ${item.name}: no source URL`;
      }
      updatePkgManagerView();
    }
  }
}
registerHandler("pkg_activate", pkg_activate);

function pkg_back_or_close() : void {
  if (!pkgState.isOpen) return;

  // If focus is on action buttons, go back to list
  if (pkgState.focus.type === "action") {
    pkgState.focus = { type: "list" };
    updatePkgManagerView();
    return;
  }

  // Otherwise close
  closePackageManager();
}
registerHandler("pkg_back_or_close", pkg_back_or_close);

function pkg_scroll_up() : void {
  // Just move cursor up in detail view
  editor.executeAction("move_up");
}
registerHandler("pkg_scroll_up", pkg_scroll_up);

function pkg_scroll_down() : void {
  // Just move cursor down in detail view
  editor.executeAction("move_down");
}
registerHandler("pkg_scroll_down", pkg_scroll_down);

function pkg_search() : void {
  if (!pkgState.isOpen) return;

  // Pre-fill with current search query so typing replaces it
  if (pkgState.searchQuery) {
    editor.startPromptWithInitial("Search packages: ", "pkg-search", pkgState.searchQuery);
  } else {
    editor.startPrompt("Search packages: ", "pkg-search");
  }
}
registerHandler("pkg_search", pkg_search);



editor.on("prompt_confirmed", (args) => {
  if (args.prompt_type !== "pkg-search") return true;

  pkgState.searchQuery = args.input.trim();
  pkgState.selectedIndex = 0;
  pkgState.focus = { type: "list" };
  updatePkgManagerView();

  return true;
});


editor.on("resize", () => {
  if (!pkgState.isOpen) return;
  const viewport = editor.getViewport();
  if (viewport) {
    pkgState.viewportHeight = viewport.height;
  }
  updatePkgManagerView();
});

// Legacy Finder-based UI (kept for backwards compatibility)
const registryFinder = new Finder<[string, RegistryEntry]>(editor, {
  id: "pkg-registry",
  format: ([name, entry]) => ({
    label: name,
    description: entry.description,
    metadata: { name, entry }
  }),
  preview: false,
  maxResults: 100,
  onSelect: async ([name, entry]) => {
    await installPackage(entry.repository, name, "plugin");
    refreshPackageManagerIfOpen();
  }
});

// =============================================================================
// Commands
// =============================================================================

/**
 * Browse and install plugins from registry
 */
async function pkg_install_plugin() : Promise<void> {
  editor.debug("[pkg] pkg_install_plugin called");
  try {
    // Always sync registry to ensure latest plugins are available
    await syncRegistry();

    const registry = loadRegistry("plugins");
    editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} packages`);
    const entries = Object.entries(registry.packages);
    editor.debug(`[pkg] entries.length = ${entries.length}`);

    if (entries.length === 0) {
      editor.debug("[pkg] No plugins found, setting status");
      editor.setStatus("No plugins in registry (registry may be empty)");
      editor.debug("[pkg] setStatus called");
      return;
    }
    editor.debug("[pkg] About to show finder");

    registryFinder.prompt({
      title: "Install Plugin:",
      source: {
        mode: "filter",
        load: async () => entries
      }
    });
  } catch (e) {
    editor.debug(`[pkg] Error in pkg_install_plugin: ${e}`);
    editor.setStatus(`Error: ${e}`);
  }
}
registerHandler("pkg_install_plugin", pkg_install_plugin);

/**
 * Browse and install themes from registry
 */
async function pkg_install_theme() : Promise<void> {
  editor.debug("[pkg] pkg_install_theme called");
  try {
    // Always sync registry to ensure latest themes are available
    await syncRegistry();

    const registry = loadRegistry("themes");
    editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} themes`);
    const entries = Object.entries(registry.packages);

    if (entries.length === 0) {
      editor.setStatus("No themes in registry (registry may be empty)");
      return;
    }

    registryFinder.prompt({
      title: "Install Theme:",
      source: {
        mode: "filter",
        load: async () => entries
      }
    });
  } catch (e) {
    editor.debug(`[pkg] Error in pkg_install_theme: ${e}`);
    editor.setStatus(`Error: ${e}`);
  }
}
registerHandler("pkg_install_theme", pkg_install_theme);

/**
 * Install from git URL, direct file URL, or local path
 */
function pkg_install_url() : void {
  editor.startPrompt("Git URL or local path:", "pkg-install-url");
}
registerHandler("pkg_install_url", pkg_install_url);



editor.on("prompt_confirmed", async (args) => {
  if (args.prompt_type !== "pkg-install-url") return true;

  const url = args.input.trim();
  if (!url) {
    editor.setStatus("No URL or path provided");
    return true;
  }

  // The handler's promise is detached from here on, so it carries its
  // own catch: an unhandled rejection is fatal to the plugin runtime.
  try {
    await installPackage(url);
  } catch (e) {
    packageFailure(`Install failed: ${e}`);
  }
  // A package installed from a URL is an installed package like any
  // other; if the browser is open it belongs in the list now, not
  // after a close and reopen.
  refreshPackageManagerIfOpen();

  return true;
});

/**
 * Open the package manager UI
 */
async function pkg_list() : Promise<void> {
  await openPackageManager();
}
registerHandler("pkg_list", pkg_list);

/**
 * Update all packages
 */
async function pkg_update_all() : Promise<void> {
  await updateAllPackages();
  refreshPackageManagerIfOpen();
}
registerHandler("pkg_update_all", pkg_update_all);

/**
 * Update a specific package
 */
function pkg_update() : void {
  const all = getAllInstalledPackages();

  if (all.length === 0) {
    editor.setStatus("No packages installed");
    return;
  }

  const finder = new Finder<InstalledPackage>(editor, {
    id: "pkg-update",
    format: (pkg) => ({
      label: pkg.name,
      description: `${pkg.type} | ${pkg.version}${pkg.localSource ? " (local)" : ""}`,
      metadata: pkg
    }),
    preview: false,
    onSelect: async (pkg) => {
      await updatePackage(pkg);
      refreshPackageManagerIfOpen();
    }
  });

  finder.prompt({
    title: "Update Package:",
    source: {
      mode: "filter",
      load: async () => all
    }
  });
}
registerHandler("pkg_update", pkg_update);

/**
 * Remove a package
 */
function pkg_remove() : void {
  const all = getAllInstalledPackages();

  if (all.length === 0) {
    editor.setStatus("No packages installed");
    return;
  }

  const finder = new Finder<InstalledPackage>(editor, {
    id: "pkg-remove",
    format: (pkg) => ({
      label: pkg.name,
      description: `${pkg.type} | ${pkg.version}`,
      metadata: pkg
    }),
    preview: false,
    onSelect: async (pkg) => {
      await removePackage(pkg);
      refreshPackageManagerIfOpen();
    }
  });

  finder.prompt({
    title: "Remove Package:",
    source: {
      mode: "filter",
      load: async () => all
    }
  });
}
registerHandler("pkg_remove", pkg_remove);

/**
 * Sync registry
 */
async function pkg_sync() : Promise<void> {
  await syncRegistry();
}
registerHandler("pkg_sync", pkg_sync);

/**
 * Show outdated packages
 */
async function pkg_outdated() : Promise<void> {
  const all = getAllInstalledPackages();

  if (all.length === 0) {
    editor.setStatus("No packages installed");
    return;
  }

  editor.setStatus("Checking for updates...");

  const outdated: Array<{ pkg: InstalledPackage; behind: number }> = [];

  let skipped = 0;
  for (const pkg of all) {
    // Only a plain-repo install has a .git to count commits against. A
    // monorepo-subpath or local-directory install is a copy, so git
    // here reports nothing and the package would silently look up to
    // date; those are updated by re-installing (see `updatePackage`).
    if (!fsLocal.fileExists(editor.pathJoin(pkg.path, ".git"))) {
      skipped++;
      continue;
    }

    // Fetch latest
    await gitCommand(["-C", `${pkg.path}`, "fetch"]);

    // Check how many commits behind
    const result = await gitCommand([
      "-C", `${pkg.path}`, "rev-list", "--count", "HEAD..origin/HEAD"
    ]);

    const behind = parseInt(result.stdout.trim(), 10);
    if (behind > 0) {
      outdated.push({ pkg, behind });
    }
  }

  if (outdated.length === 0) {
    editor.setStatus(
      skipped > 0
        ? `All git-tracked packages are up to date (${skipped} installed by copy — use Update to re-install)`
        : "All packages are up to date",
    );
    return;
  }

  const finder = new Finder<{ pkg: InstalledPackage; behind: number }>(editor, {
    id: "pkg-outdated",
    format: (item) => ({
      label: item.pkg.name,
      description: `${item.behind} commits behind`,
      metadata: item
    }),
    preview: false,
    onSelect: async (item) => {
      await updatePackage(item.pkg);
      refreshPackageManagerIfOpen();
    }
  });

  finder.prompt({
    title: `Outdated Packages (${outdated.length}):`,
    source: {
      mode: "filter",
      load: async () => outdated
    }
  });
}
registerHandler("pkg_outdated", pkg_outdated);

/**
 * Generate lockfile
 */
async function pkg_lock() : Promise<void> {
  await generateLockfile();
}
registerHandler("pkg_lock", pkg_lock);

/**
 * Install from lockfile
 */
async function pkg_install_lock() : Promise<void> {
  await installFromLockfile();
}
registerHandler("pkg_install_lock", pkg_install_lock);

// =============================================================================
// Command Registration
// =============================================================================

// Main entry point - opens the package manager UI
editor.registerCommand("%cmd.list", "%cmd.list_desc", "pkg_list", null);

// Install from URL - for packages not in registry
editor.registerCommand("%cmd.install_url", "%cmd.install_url_desc", "pkg_install_url", null);

// Note: Other commands (install_plugin, install_theme, update, remove, sync, etc.)
// are available via the package manager UI and don't need global command palette entries.

// Note: Startup loading of installed language packs and bundles is now handled
// by Rust (services::packages::scan_installed_packages) during editor init.
// The loadLanguagePack() and loadBundle() functions above are still used for
// runtime installs via the package manager UI.

editor.debug("Package Manager plugin loaded");
