#!/usr/bin/env node

"use strict";

/**
 * manage-features.js — Local feature registry manager
 * Manages .claude/features.json per-project feature tracking.
 *
 * Subcommands:
 *   init [--seed N]                                            Bootstrap features.json with explicit next_id
 *   create <name> [--id <tracker-key>] [--description "..."] [--priority N]
 *   read <feature-id>                                          Read feature details
 *   list [--status backlog|archived] [--format table|json]
 *   update <feature-id> [--description "..."] [--priority N] [--display-name "..."]
 *   archive <feature-id>
 *   restore <feature-id>
 *   resolve <query>
 *   set-remote <feature-id> --type <github|jira|linear> --url <url> [--issue-number <n>] [--repo <owner/name>]
 */

const fs = require("fs");
const path = require("path");

// ─── Constants ───────────────────────────────────────────────────────────────

const SCHEMA_VERSION = 1;

const ERROR_CODES = {
  FEATURE_NOT_FOUND: "E002",
  LOCK_TIMEOUT: "E005",
  SCHEMA_ERROR: "E006",
  DUPLICATE_ID: "E007",
  INVALID_INPUT: "E008",
  BOOTSTRAP_COLLISION: "E009",
};

// ─── File Path Resolution ─────────────────────────────────────────────────────

/**
 * Resolve path to features.json. Uses CLAUDE_PROJECT_DIR env var if set,
 * otherwise walks up from cwd looking for .claude/ directory.
 */
function resolveFeaturesPath() {
  if (process.env.CLAUDE_PROJECT_DIR) {
    const resolved = path.resolve(process.env.CLAUDE_PROJECT_DIR);
    if (!path.isAbsolute(resolved) || resolved.split(path.sep).includes("..")) {
      console.error(
        `[${ERROR_CODES.INVALID_INPUT}] INVALID_INPUT: CLAUDE_PROJECT_DIR contains path traversal or is not absolute: ${process.env.CLAUDE_PROJECT_DIR}`,
      );
      process.exit(1);
    }
    return path.join(resolved, ".claude", "features.json");
  }

  let dir = process.cwd();
  while (true) {
    const claudeDir = path.join(dir, ".claude");
    if (fs.existsSync(claudeDir) && fs.statSync(claudeDir).isDirectory()) {
      return path.join(claudeDir, "features.json");
    }
    const parent = path.dirname(dir);
    if (parent === dir) {
      // Reached filesystem root — fall back to cwd
      return path.join(process.cwd(), ".claude", "features.json");
    }
    dir = parent;
  }
}

function resolveLifecyclePath() {
  const featuresPath = resolveFeaturesPath();
  return path.join(path.dirname(featuresPath), "lifecycle.json");
}

function resolveRegistryPath() {
  const featuresPath = resolveFeaturesPath();
  return path.join(path.dirname(featuresPath), "lifecycle-registry.json");
}

function resolveStatePath() {
  const featuresPath = resolveFeaturesPath();
  return path.join(path.dirname(featuresPath), "lifecycle-state.json");
}

// ─── File Locking ─────────────────────────────────────────────────────────────

const LOCK_RETRY_MS = 50;
const LOCK_DEADLINE_MS = 5000;
const LOCK_STALE_MS = 10000;

function resolveLockPath() {
  return resolveFeaturesPath() + ".lock";
}

/**
 * Acquire an exclusive file lock using O_CREAT|O_EXCL (wx).
 * Spin-retries with jitter up to a 5s deadline. Detects stale locks via mtime.
 * Writes PID to lock file for diagnostics.
 * Returns a release function.
 */
function acquireLock() {
  const lockPath = resolveLockPath();
  const dir = path.dirname(lockPath);
  fs.mkdirSync(dir, { recursive: true });

  const start = Date.now();
  while (true) {
    try {
      const fd = fs.openSync(lockPath, "wx");
      fs.writeSync(fd, String(process.pid));
      fs.closeSync(fd);
      return function release() {
        try {
          fs.unlinkSync(lockPath);
        } catch {
          /* ignore — already released */
        }
      };
    } catch (err) {
      if (err.code !== "EEXIST") throw err;

      // Check for stale lock
      try {
        const stat = fs.statSync(lockPath);
        if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
          // Stale lock — break it
          try {
            fs.unlinkSync(lockPath);
          } catch {
            /* race — another process may have already broken it */
          }
          // Small jitter before retrying to reduce thundering herd when multiple
          // processes simultaneously detect and break the same stale lock. (#631)
          const staleJitter = Math.random() * LOCK_RETRY_MS;
          const staleWaitUntil = Date.now() + staleJitter;
          while (Date.now() < staleWaitUntil) {
            /* busy wait */
          }
          continue;
        }
      } catch {
        // Lock disappeared between open and stat — retry
        continue;
      }

      if (Date.now() - start >= LOCK_DEADLINE_MS) {
        console.error(
          `[${ERROR_CODES.LOCK_TIMEOUT}] LOCK_TIMEOUT: Could not acquire lock within ${LOCK_DEADLINE_MS}ms: ${lockPath}`,
        );
        process.exit(1);
      }

      // Spin-wait with jitter — intentional busy wait since Node.js has no
      // synchronous sleep. Pins a CPU core for up to ~100ms per iteration, which
      // is acceptable for a CLI tool. Same pattern in update-lifecycle.js and
      // sync-tracker.js. (#623)
      const jitter = Math.random() * LOCK_RETRY_MS;
      const waitUntil = Date.now() + LOCK_RETRY_MS + jitter;
      while (Date.now() < waitUntil) {
        /* busy wait */
      }
    }
  }
}

/**
 * Execute fn() while holding the features lock.
 * Lock is released in the finally block AND via a process exit handler.
 *
 * NOTE: process.exit() inside fn() does NOT unwind the call stack, so the
 * finally block will NOT run. However, the "exit" event listener registered
 * below WILL fire on process.exit(), ensuring the lock file is always cleaned
 * up. This dual-release strategy (finally + exit listener) matches the pattern
 * in update-lifecycle.js withLifecycleLock. (#622)
 */
function withFeaturesLock(fn) {
  const release = acquireLock();
  const onExit = () => release();
  process.on("exit", onExit);
  try {
    return fn();
  } finally {
    process.removeListener("exit", onExit);
    release();
  }
}

// ─── Features File ────────────────────────────────────────────────────────────

function emptyStore(projectKey) {
  return {
    schema_version: SCHEMA_VERSION,
    next_id: 1,
    project_key: projectKey || "",
    features: {},
  };
}

function readFeatures() {
  const filePath = resolveFeaturesPath();
  if (!fs.existsSync(filePath)) {
    return null;
  }
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf-8"));
  } catch (err) {
    console.error(
      `[${ERROR_CODES.SCHEMA_ERROR}] SCHEMA_ERROR: Failed to parse features.json: ${err.message}`,
    );
    process.exit(1);
  }
}

/**
 * Read features.json, auto-creating it if it doesn't exist.
 */
function requireFeatures() {
  const data = readFeatures();
  if (!data) {
    const projectKey = readProjectKeyFromConstants();
    return emptyStore(projectKey);
  }
  return data;
}

function writeFeatures(data) {
  const filePath = resolveFeaturesPath();
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  const tmpPath = filePath + ".tmp";
  try {
    fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
  } catch (err) {
    // Clean up orphaned .tmp file before re-throwing (#632)
    try {
      fs.unlinkSync(tmpPath);
    } catch {
      /* ignore — file may not exist */
    }
    throw err;
  }
  fs.renameSync(tmpPath, filePath);
}

// ─── project-constants.md Parsing ────────────────────────────────────────────

/**
 * Read PROJECT_KEY from project-constants.md if it exists.
 * Returns empty string if not found or file doesn't exist.
 */
function readProjectKeyFromConstants() {
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
  const constantsPath = path.join(cwd, ".claude", "project-constants.md");
  if (!fs.existsSync(constantsPath)) return "";
  try {
    const content = fs.readFileSync(constantsPath, "utf-8");
    const m = content.match(/PROJECT_KEY\s*\|\s*([^\s|]+)/);
    return m ? m[1].trim() : "";
  } catch {
    return "";
  }
}

// ─── Prior-ID Scanning ────────────────────────────────────────────────────────

/**
 * Scan the project for pre-existing {projectKey}-N feature IDs in known
 * locations. Used to detect bootstrap collisions before initialising
 * features.json with next_id=1 on a project that already has features.
 *
 * Sources:
 *   1. docs/features/<KEY>-N-<slug>/ folder names
 *   2. .claude/lifecycle-registry.json features map keys (legacy/v7)
 *
 * Returns a sorted, de-duplicated array of numeric IDs. Empty when no prior
 * IDs are present or projectKey is empty.
 */
function scanForPriorIds(projectKey, projectDir) {
  if (!projectKey) return [];
  // Anchor on prefix end so "ECK-DEV" doesn't match "ECK-2"
  const idRe = new RegExp(
    "^" + projectKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-(\\d+)(?:-|$)",
  );
  const found = new Set();

  const docsDir = path.join(projectDir, "docs", "features");
  if (fs.existsSync(docsDir)) {
    try {
      for (const entry of fs.readdirSync(docsDir, { withFileTypes: true })) {
        if (!entry.isDirectory()) continue;
        const m = entry.name.match(idRe);
        if (m) found.add(parseInt(m[1], 10));
      }
    } catch {
      /* permission denied or other — treat as no prior IDs from this source */
    }
  }

  const registryPath = path.join(
    projectDir,
    ".claude",
    "lifecycle-registry.json",
  );
  if (fs.existsSync(registryPath)) {
    try {
      const reg = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
      if (reg && reg.features && typeof reg.features === "object") {
        for (const key of Object.keys(reg.features)) {
          const m = key.match(idRe);
          if (m) found.add(parseInt(m[1], 10));
        }
      }
    } catch {
      /* invalid JSON or read error — treat as no prior IDs from this source */
    }
  }

  return Array.from(found).sort((a, b) => a - b);
}

// ─── ID Generation ────────────────────────────────────────────────────────────

/**
 * Generate next sequential feature ID using project_key (e.g., WA-1, ECK-2).
 * Falls back to <KEY>-N when project_key is empty.
 * Increments data.next_id.
 */
function nextFeatureId(data) {
  // Validate next_id is a positive integer — silent fallback would mask corruption (#644)
  if (
    typeof data.next_id !== "number" ||
    !Number.isInteger(data.next_id) ||
    data.next_id < 1
  ) {
    console.error(
      `[${ERROR_CODES.SCHEMA_ERROR}] SCHEMA_ERROR: features.json next_id is corrupt or missing (got: ${JSON.stringify(data.next_id)}). Manual repair required.`,
    );
    process.exit(1);
  }
  const prefix = data.project_key || "FEAT";
  const id = `${prefix}-${data.next_id}`;
  if (data.features[id]) {
    console.error(
      `[${ERROR_CODES.DUPLICATE_ID}] DUPLICATE_ID: Generated ID already exists: ${id}`,
    );
    process.exit(1);
  }
  data.next_id = data.next_id + 1;
  return id;
}

// ─── Slug Derivation ─────────────────────────────────────────────────────────

function toSlug(name) {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

// ─── Argument Parsing ─────────────────────────────────────────────────────────

function parseFlag(args, flag) {
  const idx = args.indexOf(flag);
  return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
}

// ─── Lifecycle Integration ────────────────────────────────────────────────────

/**
 * Read lifecycle data. Tries split files (v6+) first, falls back to lifecycle.json.
 * Returns null if not found.
 */
function readLifecycle() {
  // Try split files first (v6+)
  const regPath = resolveRegistryPath();
  if (fs.existsSync(regPath)) {
    try {
      const registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
      let activeFeature = null;
      try {
        const statePath = resolveStatePath();
        if (fs.existsSync(statePath)) {
          const state = JSON.parse(fs.readFileSync(statePath, "utf-8"));
          activeFeature = state.active_feature || null;
        }
      } catch {
        /* ignore */
      }
      return {
        schema_version: registry.schema_version,
        active_feature: activeFeature,
        features: registry.features,
        project_key: registry.project_key || null,
      };
    } catch {
      /* fall through */
    }
  }
  // Legacy fallback
  const lifecyclePath = resolveLifecyclePath();
  if (!fs.existsSync(lifecyclePath)) return null;
  try {
    return JSON.parse(fs.readFileSync(lifecyclePath, "utf-8"));
  } catch {
    return null;
  }
}

/**
 * Derive a feature's status from lifecycle.json data.
 * Returns: "active" | "parked" | "done" | "backlog" | "archived"
 *
 * - archived_at set in features.json → "archived"
 * - Not present in lifecycle.json → "backlog"
 * - archived_at set in lifecycle.json → "archived"
 * - completed_at set → "done"
 * - has first_started_at → "active" or "parked" based on lifecycle active_feature
 * - otherwise → "backlog"
 */
function deriveFeatureStatus(feature, lifecycleData) {
  if (feature.archived_at) return "archived";

  if (!lifecycleData) return "backlog";

  const slug = feature.slug;
  const id = feature.id;
  const lifecycleEntry = lifecycleData.features
    ? lifecycleData.features[slug] || lifecycleData.features[id] || null
    : null;

  if (!lifecycleEntry) return "backlog";

  if (lifecycleEntry.archived_at) return "archived";
  if (lifecycleEntry.completed_at) return "done";
  if (lifecycleEntry.first_started_at) {
    if (
      lifecycleData.active_feature === slug ||
      lifecycleData.active_feature === id
    )
      return "active";
    return "parked";
  }
  return "backlog";
}

// ─── Subcommands ─────────────────────────────────────────────────────────────

const FEATURE_ID_RE = /^[A-Z][A-Z0-9]{0,9}(?:-[A-Z0-9]+)*-\d+$/;

function cmdCreate(args) {
  const name = parseFlag(args, "--name") || args[0];
  if (!name || /^--[a-zA-Z]/.test(name)) {
    console.error(
      'Usage: create --name "Feature Name" [--description "..."] [--priority N]',
    );
    console.error("       create <name> [--description ...] [--priority N]");
    process.exit(1);
  }

  const description = parseFlag(args, "--description");
  const priorityRaw = parseFlag(args, "--priority");
  const priority = priorityRaw !== null ? Number(priorityRaw) : null;
  if (priorityRaw !== null && (priority === null || isNaN(priority))) {
    console.error(`Invalid priority value: ${priorityRaw} (must be a number)`);
    process.exit(1);
  }

  const idArg = parseFlag(args, "--id");

  if (idArg !== null && !FEATURE_ID_RE.test(idArg)) {
    process.stderr.write(
      `[${ERROR_CODES.INVALID_INPUT}] Invalid --id "${idArg}": must match feature-ID shape ^[A-Z][A-Z0-9]{0,9}(?:-[A-Z0-9]+)*-\\d+$\n`,
    );
    process.exit(1);
  }

  // Bootstrap collision detection: refuse to silently start at next_id=1 on a
  // project that already carries <KEY>-N IDs in folders or the legacy
  // lifecycle-registry. Skipped when --id is supplied (caller is explicit) or
  // when features.json already exists.
  if (idArg === null && !fs.existsSync(resolveFeaturesPath())) {
    const projectKey = readProjectKeyFromConstants();
    if (projectKey) {
      const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
      const priorIds = scanForPriorIds(projectKey, projectDir);
      if (priorIds.length > 0) {
        const idList = priorIds.map((n) => `${projectKey}-${n}`).join(", ");
        const suggestedSeed = priorIds[priorIds.length - 1] + 1;
        process.stderr.write(
          `[${ERROR_CODES.BOOTSTRAP_COLLISION}] BOOTSTRAP_COLLISION: ` +
            `Detected ${priorIds.length} prior feature ID(s) for project ${projectKey}: ${idList}\n` +
            `\n` +
            `features.json does not exist yet, so auto-numbering would start at ${projectKey}-1 and collide.\n` +
            `\n` +
            `Resolve by either:\n` +
            `  1. Bootstrap features.json with an explicit seed:\n` +
            `       node manage-features.js init --seed ${suggestedSeed}\n` +
            `  2. Supply an explicit ID per call:\n` +
            `       node manage-features.js create --id ${projectKey}-${suggestedSeed} --name "..."\n`,
        );
        process.exit(1);
      }
    }
  }

  withFeaturesLock(() => {
    const data = requireFeatures();

    let id;
    if (idArg !== null) {
      const projectKey = data.project_key || "";
      if (projectKey && !idArg.startsWith(projectKey + "-")) {
        process.stderr.write(
          `Warning: --id "${idArg}" does not start with PROJECT_KEY "${projectKey}". Expected prefix: "${projectKey}-".\n`,
        );
      }
      if (data.features[idArg] != null) {
        process.stderr.write(
          `[DUPLICATE_ID] DUPLICATE_ID: ${idArg} already exists in features.json\n`,
        );
        process.exit(1);
      }
      id = idArg;
    } else {
      if (data._id_strategy === "manual-jira-aligned") {
        process.stderr.write(
          `features.json._id_strategy="manual-jira-aligned" requires --id. Supply --id <TRACKER_KEY> (e.g., from JIRA/Linear).\n`,
        );
        process.exit(1);
      }
      id = nextFeatureId(data);
    }

    const slug = toSlug(name);
    const now = new Date().toISOString();

    data.features[id] = {
      id,
      slug,
      display_name: name,
      description: description || null,
      priority,
      created_at: now,
      updated_at: now,
      remote: null,
      archived_at: null,
    };

    writeFeatures(data);

    console.log(JSON.stringify(data.features[id], null, 2));
  });
}

function cmdRead(args) {
  const featureId = args[0];
  if (!featureId) {
    console.error("Usage: read <feature-id>");
    process.exit(1);
  }

  const data = requireFeatures();
  const feature = data.features[featureId];
  if (!feature) {
    console.error(
      `[${ERROR_CODES.FEATURE_NOT_FOUND}] FEATURE_NOT_FOUND: Feature not found: ${featureId}`,
    );
    process.exit(1);
  }

  console.log(JSON.stringify(feature, null, 2));
}

function cmdList(args) {
  const filterStatus = parseFlag(args, "--status");
  const format = parseFlag(args, "--format") || "json";

  const VALID_LIST_STATUSES = [
    "backlog",
    "archived",
    "active",
    "parked",
    "done",
  ];
  if (filterStatus && !VALID_LIST_STATUSES.includes(filterStatus)) {
    console.error(
      `Invalid status filter: ${filterStatus}. Valid: ${VALID_LIST_STATUSES.join(", ")}`,
    );
    process.exit(1);
  }

  const data = requireFeatures();
  const lifecycleData = readLifecycle();

  let features = [];
  for (const [, feature] of Object.entries(data.features)) {
    const status = deriveFeatureStatus(feature, lifecycleData);
    if (filterStatus && status !== filterStatus) continue;
    features.push({ ...feature, status });
  }

  // Sort by created_at ascending
  features.sort((a, b) =>
    (a.created_at || "").localeCompare(b.created_at || ""),
  );

  if (format === "table") {
    // Simple text table output
    const header = `${"ID".padEnd(10)} ${"SLUG".padEnd(35)} ${"DISPLAY NAME".padEnd(35)} ${"STATUS".padEnd(10)} ${"PRIORITY"}`;
    const divider = "-".repeat(header.length);
    console.log(header);
    console.log(divider);
    for (const f of features) {
      const row = [
        f.id.padEnd(10),
        (f.slug || "").substring(0, 34).padEnd(35),
        (f.display_name || "").substring(0, 34).padEnd(35),
        (f.status || "").padEnd(10),
        f.priority != null ? String(f.priority) : "—",
      ].join(" ");
      console.log(row);
    }
    console.log(`\n${features.length} feature(s)`);
  } else {
    console.log(JSON.stringify({ features, count: features.length }, null, 2));
  }
}

function cmdUpdate(args) {
  const featureId = args[0];
  if (!featureId) {
    console.error(
      'Usage: update <feature-id> [--description "..."] [--priority N] [--display-name "..."]',
    );
    process.exit(1);
  }

  const newDescription = parseFlag(args, "--description");
  const newPriorityRaw = parseFlag(args, "--priority");
  const newDisplayName = parseFlag(args, "--display-name");

  withFeaturesLock(() => {
    const data = requireFeatures();
    const feature = data.features[featureId];
    if (!feature) {
      console.error(
        `[${ERROR_CODES.FEATURE_NOT_FOUND}] FEATURE_NOT_FOUND: Feature not found: ${featureId}`,
      );
      process.exit(1);
    }

    const updatedFields = [];

    if (newDisplayName !== null && newDisplayName !== undefined) {
      feature.display_name = newDisplayName;
      feature.slug = toSlug(newDisplayName);
      updatedFields.push("display_name", "slug");
    }
    if (newDescription !== null && newDescription !== undefined) {
      feature.description = newDescription || null;
      updatedFields.push("description");
    }
    if (newPriorityRaw !== null && newPriorityRaw !== undefined) {
      const newPriority = Number(newPriorityRaw);
      if (isNaN(newPriority)) {
        console.error(
          `Invalid priority value: ${newPriorityRaw} (must be a number)`,
        );
        process.exit(1);
      }
      feature.priority = newPriority;
      updatedFields.push("priority");
    }

    if (updatedFields.length === 0) {
      console.error(
        "No fields to update. Provide at least one of: --description, --priority, --display-name",
      );
      process.exit(1);
    }

    feature.updated_at = new Date().toISOString();

    writeFeatures(data);

    console.log(
      JSON.stringify({ id: featureId, updated_fields: updatedFields }),
    );
  });
}

function cmdArchive(args) {
  const featureId = args[0];
  if (!featureId) {
    console.error("Usage: archive <feature-id>");
    process.exit(1);
  }

  withFeaturesLock(() => {
    const data = requireFeatures();
    const feature = data.features[featureId];
    if (!feature) {
      console.error(
        `[${ERROR_CODES.FEATURE_NOT_FOUND}] FEATURE_NOT_FOUND: Feature not found: ${featureId}`,
      );
      process.exit(1);
    }

    if (feature.archived_at) {
      console.error(`Feature is already archived: ${featureId}`);
      process.exit(1);
    }

    const now = new Date().toISOString();
    feature.archived_at = now;
    feature.updated_at = now;

    writeFeatures(data);

    console.log(JSON.stringify({ id: featureId, archived_at: now }));
  });
}

function cmdRestore(args) {
  const featureId = args[0];
  if (!featureId) {
    console.error("Usage: restore <feature-id>");
    process.exit(1);
  }

  withFeaturesLock(() => {
    const data = requireFeatures();
    const feature = data.features[featureId];
    if (!feature) {
      console.error(
        `[${ERROR_CODES.FEATURE_NOT_FOUND}] FEATURE_NOT_FOUND: Feature not found: ${featureId}`,
      );
      process.exit(1);
    }

    if (!feature.archived_at) {
      console.error(`Feature is not archived: ${featureId}`);
      process.exit(1);
    }

    feature.archived_at = null;
    feature.updated_at = new Date().toISOString();

    writeFeatures(data);

    console.log(JSON.stringify({ id: featureId, archived_at: null }));
  });
}

// ─── Init ─────────────────────────────────────────────────────────────────────

function cmdInit(args) {
  if (fs.existsSync(resolveFeaturesPath())) {
    process.stderr.write(
      `[${ERROR_CODES.INVALID_INPUT}] features.json already exists at ${resolveFeaturesPath()}. ` +
        `Init is for first-time bootstrap only — delete the file or edit next_id directly to change the counter.\n`,
    );
    process.exit(1);
  }

  const seedRaw = parseFlag(args, "--seed");
  let seed = 1;
  if (seedRaw !== null) {
    const parsed = Number(seedRaw);
    if (!Number.isInteger(parsed) || parsed < 1) {
      process.stderr.write(
        `[${ERROR_CODES.INVALID_INPUT}] Invalid --seed "${seedRaw}": must be a positive integer.\n`,
      );
      process.exit(1);
    }
    seed = parsed;
  }

  withFeaturesLock(() => {
    const projectKey = readProjectKeyFromConstants();
    const data = emptyStore(projectKey);
    data.next_id = seed;
    writeFeatures(data);

    console.log(JSON.stringify(data, null, 2));
  });
}

// ─── SetRemote ────────────────────────────────────────────────────────────────

function cmdSetRemote(args) {
  const SET_REMOTE_USAGE =
    "Usage: set-remote <feature-id> --type <github|jira|linear> --url <url> [--issue-number <n>] [--repo <owner/name>]";

  const featureId = args[0];
  if (!featureId || /^--[a-zA-Z]/.test(featureId)) {
    console.error(SET_REMOTE_USAGE);
    process.exit(1);
  }

  const type = parseFlag(args, "--type");
  const url = parseFlag(args, "--url");

  if (!type) {
    console.error(SET_REMOTE_USAGE);
    process.exit(1);
  }

  if (!url) {
    console.error(SET_REMOTE_USAGE);
    process.exit(1);
  }

  const issueNumberRaw = parseFlag(args, "--issue-number");
  const repo = parseFlag(args, "--repo");

  withFeaturesLock(() => {
    const data = requireFeatures();
    const feature = data.features[featureId];
    if (!feature) {
      console.error(
        `[${ERROR_CODES.FEATURE_NOT_FOUND}] FEATURE_NOT_FOUND: Feature not found: ${featureId}`,
      );
      process.exit(1);
    }

    const remote = { type, url };
    if (repo !== null) remote.repo = repo;
    if (issueNumberRaw !== null) remote.issue_number = Number(issueNumberRaw);

    feature.remote = remote;
    feature.updated_at = new Date().toISOString();

    writeFeatures(data);

    console.log(JSON.stringify({ id: featureId, remote }));
  });
}

// ─── Resolve ──────────────────────────────────────────────────────────────────

/**
 * Resolve a feature by query using fuzzy matching.
 *
 * Priority order:
 *   1. Exact <KEY>-XXX match (case-insensitive, normalized to uppercase)
 *   2. Numeric shorthand (e.g. "42" → <KEY>-42)
 *   3. Exact slug match
 *   4. Partial slug match (non-archived only)
 *   5. Partial display_name match (non-archived only)
 *
 * Returns { id, confidence: "exact"|"fuzzy" } or calls process.exit(1).
 */
function resolveFeature(query, data) {
  const features = data.features;
  // Step 1: Exact <KEY>-N match (1-10 character key prefix + 1+ digits) (#645)
  if (/^[A-Z][A-Z0-9]{0,9}-\d+$/i.test(query)) {
    const normalizedId = query.toUpperCase();
    if (features[normalizedId]) {
      return { id: normalizedId, confidence: "exact" };
    }
    console.error(`No feature found matching: ${query}`);
    process.exit(1);
  }

  // Step 2: Numeric shorthand
  if (/^\d+$/.test(query)) {
    const prefix = data.project_key || "FEAT";
    const id = `${prefix}-${Number(query)}`;
    if (features[id]) {
      return { id, confidence: "exact" };
    }
    console.error(`No feature found matching: ${query}`);
    process.exit(1);
  }

  const lowerQuery = query.toLowerCase();

  // Step 3: Exact slug match (all features including archived)
  for (const feature of Object.values(features)) {
    if (feature.slug === lowerQuery) {
      return { id: feature.id, confidence: "exact" };
    }
  }

  // Steps 4–5: Partial matches (non-archived only)
  const matches = [];
  const seen = new Set();

  for (const feature of Object.values(features)) {
    if (feature.archived_at) continue;

    // Step 4: Partial slug match
    if (feature.slug.includes(lowerQuery)) {
      if (!seen.has(feature.id)) {
        matches.push(feature.id);
        seen.add(feature.id);
      }
      continue;
    }

    // Step 5: Partial display_name match
    if (
      feature.display_name &&
      feature.display_name.toLowerCase().includes(lowerQuery)
    ) {
      if (!seen.has(feature.id)) {
        matches.push(feature.id);
        seen.add(feature.id);
      }
    }
  }

  if (matches.length === 1) {
    return { id: matches[0], confidence: "fuzzy" };
  }

  if (matches.length === 0) {
    console.error(`No feature found matching: ${query}`);
    process.exit(1);
  }

  // Ambiguous
  console.error(
    `Ambiguous query: ${query} — candidates: ${matches.join(", ")}`,
  );
  process.exit(1);
}

function cmdResolve(args) {
  const query = args[0];
  if (!query) {
    console.error("Usage: resolve <query>");
    process.exit(1);
  }

  const data = requireFeatures();
  const result = resolveFeature(query, data);
  console.log(JSON.stringify(result));
}

// ─── Main ─────────────────────────────────────────────────────────────────────

function main() {
  const args = process.argv.slice(2);
  const command = args[0];
  const rest = args.slice(1);

  switch (command) {
    case "init":
      cmdInit(rest);
      break;
    case "create":
      cmdCreate(rest);
      break;
    case "read":
      cmdRead(rest);
      break;
    case "list":
      cmdList(rest);
      break;
    case "update":
      cmdUpdate(rest);
      break;
    case "archive":
      cmdArchive(rest);
      break;
    case "restore":
      cmdRestore(rest);
      break;
    case "resolve":
      cmdResolve(rest);
      break;
    case "set-remote":
      cmdSetRemote(rest);
      break;
    default:
      console.error(
        "Usage: manage-features.js <init|create|read|list|update|archive|restore|resolve|set-remote> [args]",
      );
      process.exit(1);
  }
}

main();
