#!/usr/bin/env node

"use strict";

/**
 * sync-tracker.js — External tracker sync for local task state
 * Reads .claude/tasks.json (managed by manage-tasks.js) and syncs changes
 * to external trackers via changelog-driven push.
 *
 * Subcommands:
 *   preflight                       Test tracker connectivity, enable sync on success
 *   push [task-id]                  Push unsynced changelog entries to tracker
 *   status                          Report sync state
 *   reconcile [--dry-run]           Compare local vs remote state and report drift
 *   pull-status                     Fetch remote status for all linked tasks, update local
 *   pull-ids                        Search remote tracker for issues matching task titles
 *   push-feature <KEY>-N           Create a remote issue for a feature and update features.json
 */

const fs = require("fs");
const path = require("path");
const { execSync, execFileSync } = require("child_process");

// ─── .env Loading (AC-29) ─────────────────────────────────────────────────────

/**
 * Load .env from the project root (the directory containing .claude/).
 * Parses KEY=VALUE lines; skips comments and blank lines.
 * Only sets vars NOT already in process.env (env vars take precedence).
 * Keys recognised: JIRA_BASE_URL, JIRA_USER_EMAIL, JIRA_API_TOKEN,
 *   JIRA_PROJECT_KEY, LINEAR_API_KEY, LINEAR_TEAM_ID
 */
function loadDotEnv() {
  // Locate project root: walk up from cwd looking for .claude/
  let dir = process.cwd();
  let projectRoot = null;
  while (true) {
    const claudeDir = path.join(dir, ".claude");
    if (fs.existsSync(claudeDir) && fs.statSync(claudeDir).isDirectory()) {
      projectRoot = dir;
      break;
    }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  if (!projectRoot) return;

  const envPath = path.join(projectRoot, ".env");
  if (!fs.existsSync(envPath)) return;

  let content;
  try {
    content = fs.readFileSync(envPath, "utf-8");
  } catch {
    return;
  }

  for (const raw of content.split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line.startsWith("#")) continue;
    const eqIdx = line.indexOf("=");
    if (eqIdx < 1) continue;
    const key = line.slice(0, eqIdx).trim();
    const val = line
      .slice(eqIdx + 1)
      .trim()
      .replace(/^["']|["']$/g, "");
    if (key && !(key in process.env)) {
      process.env[key] = val;
    }
  }
}

loadDotEnv();

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

const ERROR_CODES = {
  NOT_INITIALIZED: "S001",
  SYNC_DISABLED: "S002",
  AUTH_FAILED: "S003",
  NETWORK_ERROR: "S004",
  REMOTE_NOT_FOUND: "S005",
  RATE_LIMITED: "S006",
  PARTIAL_SYNC: "S007",
};

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

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

function resolveLockPath() {
  return resolveTasksPath() + ".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) {
          try {
            fs.unlinkSync(lockPath);
          } catch {
            /* race — another process may have already broken it */
          }
          continue;
        }
      } catch {
        // Lock disappeared between open and stat — retry
        continue;
      }

      if (Date.now() - start >= LOCK_DEADLINE_MS) {
        console.error(
          `[E005] 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 manage-features.js and
      // update-lifecycle.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 tasks lock.
 */
function withTasksLock(fn) {
  const release = acquireLock();
  const onExit = () => release();
  process.on("exit", onExit);
  try {
    return fn();
  } finally {
    process.removeListener("exit", onExit);
    release();
  }
}

// ─── Tasks File ───────────────────────────────────────────────────────────────

/**
 * Resolve the path to tasks.json by walking up from cwd looking for .claude/
 */
function resolveTasksPath() {
  if (process.env.CLAUDE_PROJECT_DIR) {
    return path.join(process.env.CLAUDE_PROJECT_DIR, ".claude", "tasks.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, "tasks.json");
    }
    const parent = path.dirname(dir);
    if (parent === dir) {
      return path.join(process.cwd(), ".claude", "tasks.json");
    }
    dir = parent;
  }
}

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

function writeTasks(data) {
  const filePath = resolveTasksPath();
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  const tmpPath = filePath + ".tmp";
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
  fs.renameSync(tmpPath, filePath);
}

function requireTasks() {
  const data = readTasks();
  if (!data) {
    console.error(
      `[${ERROR_CODES.NOT_INITIALIZED}] NOT_INITIALIZED: tasks.json does not exist. Run 'manage-tasks.js init' first.`,
    );
    process.exit(1);
  }
  return data;
}

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

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

// ─── GitHub Adapter ───────────────────────────────────────────────────────────

/**
 * Parse owner/repo from a GitHub URL.
 * Handles: https://github.com/owner/repo and https://github.com/owner/repo.git
 */
function parseGitHubRepo(trackerUrl) {
  if (!trackerUrl) {
    throw new Error("sync.tracker_url is not set in tasks.json");
  }
  const m = trackerUrl.match(
    /github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:\/|$)/,
  );
  if (!m) {
    throw new Error(
      `Cannot parse GitHub owner/repo from tracker_url: ${trackerUrl}`,
    );
  }
  return `${m[1]}/${m[2]}`;
}

/**
 * Run a command synchronously and return stdout as a string.
 * Uses execFileSync (no shell) to prevent command injection.
 * @param {string} bin - The binary to execute
 * @param {string[]} args - Arguments array
 * @returns {string} stdout
 */
function runCommand(bin, args) {
  return execFileSync(bin, args, {
    encoding: "utf-8",
    stdio: ["pipe", "pipe", "pipe"],
  });
}

class GitHubAdapter {
  /**
   * @param {string} trackerUrl - e.g. https://github.com/owner/repo
   */
  constructor(trackerUrl) {
    this.trackerUrl = trackerUrl;
    this.repo = parseGitHubRepo(trackerUrl);
  }

  /**
   * Test GitHub CLI authentication.
   * @returns {{ ok: boolean, error?: string }}
   */
  preflight() {
    try {
      runCommand("gh", ["auth", "status", "--hostname", "github.com"]);
      return { ok: true };
    } catch (err) {
      const stderr = err.stderr || err.message || "";
      if (stderr.includes("rate limit") || stderr.includes("secondary rate")) {
        return {
          ok: false,
          error: `[${ERROR_CODES.RATE_LIMITED}] RATE_LIMITED: ${stderr.trim()}`,
        };
      }
      return {
        ok: false,
        error: `[${ERROR_CODES.AUTH_FAILED}] AUTH_FAILED: ${stderr.trim() || err.message}`,
      };
    }
  }

  /**
   * Create a new GitHub issue for the task.
   * @param {string} taskId
   * @param {object} task
   * @returns {{ number: number, url: string }}
   */
  createIssue(taskId, task) {
    const title = `${taskId}: ${task.title}`;
    const body = this._buildBody(task);
    const labels = this._buildLabels(task);

    const args = [
      "issue",
      "create",
      "--repo",
      this.repo,
      "--title",
      title,
      "--body",
      body,
    ];
    if (labels.length > 0) {
      args.push("--label", labels.join(","));
    }

    let stdout;
    try {
      stdout = runCommand("gh", args);
    } catch (err) {
      throw this._wrapError(err, "createIssue");
    }

    // gh issue create outputs the URL on stdout, e.g.:
    // https://github.com/owner/repo/issues/42
    const url = stdout.trim();
    const numMatch = url.match(/\/issues\/(\d+)$/);
    if (!numMatch) {
      throw new Error(`Unexpected output from gh issue create: ${stdout}`);
    }
    return { number: parseInt(numMatch[1], 10), url };
  }

  /**
   * Update an existing GitHub issue.
   * @param {number|string} issueNumber
   * @param {object} task - Current task state
   */
  updateIssue(issueNumber, task) {
    const title = task.title;
    const body = this._buildBody(task);
    const labels = this._buildLabels(task);

    const args = [
      "issue",
      "edit",
      String(issueNumber),
      "--repo",
      this.repo,
      "--title",
      title,
      "--body",
      body,
    ];
    if (labels.length > 0) {
      args.push("--add-label", labels.join(","));
    }

    try {
      runCommand("gh", args);
    } catch (err) {
      throw this._wrapError(err, "updateIssue");
    }
  }

  /**
   * Close a GitHub issue.
   * @param {number|string} issueNumber
   */
  closeIssue(issueNumber) {
    const args = [
      "issue",
      "close",
      String(issueNumber),
      "--repo",
      this.repo,
      "--reason",
      "completed",
    ];
    try {
      runCommand("gh", args);
    } catch (err) {
      throw this._wrapError(err, "closeIssue");
    }
  }

  /**
   * Fetch a single remote issue and return a normalised object.
   * @param {string|number} remoteId  - GitHub issue number
   * @returns {{ id: string, status: string, title: string, url: string }}
   */
  readIssue(remoteId) {
    const args = [
      "issue",
      "view",
      String(remoteId),
      "--repo",
      this.repo,
      "--json",
      "number,title,state,url",
    ];
    let stdout;
    try {
      stdout = runCommand("gh", args);
    } catch (err) {
      throw this._wrapError(err, "readIssue");
    }
    let parsed;
    try {
      parsed = JSON.parse(stdout);
    } catch {
      throw new Error(`readIssue: unexpected output from gh: ${stdout}`);
    }
    // Normalise GitHub state (OPEN/CLOSED) to local status vocabulary
    const stateMap = { OPEN: "in-progress", CLOSED: "done" };
    return {
      id: String(parsed.number),
      status: stateMap[parsed.state] || parsed.state.toLowerCase(),
      title: parsed.title,
      url: parsed.url,
    };
  }

  /**
   * Search for GitHub issues whose title starts with the given prefix.
   * Returns an array of { id, title, url } objects.
   * @param {string} titlePrefix
   * @returns {Array<{ id: string, title: string, url: string }>}
   */
  searchIssues(titlePrefix) {
    // Use the GitHub search query via gh issue list with --search
    const query = `in:title ${titlePrefix}`;
    const args = [
      "issue",
      "list",
      "--repo",
      this.repo,
      "--search",
      query,
      "--state",
      "all",
      "--json",
      "number,title,url",
      "--limit",
      "10",
    ];
    let stdout;
    try {
      stdout = runCommand("gh", args);
    } catch (err) {
      throw this._wrapError(err, "searchIssues");
    }
    let items;
    try {
      items = JSON.parse(stdout);
    } catch {
      return [];
    }
    return items.map((item) => ({
      id: String(item.number),
      title: item.title,
      url: item.url,
    }));
  }

  // ─── Private helpers ───────────────────────────────────────────────────────

  _buildBody(task) {
    const lines = [];
    if (task.description) {
      lines.push(task.description);
      lines.push("");
    }
    if (task.ac_ids && task.ac_ids.length > 0) {
      lines.push(`**Acceptance Criteria:** ${task.ac_ids.join(", ")}`);
    }
    if (task.dependencies && task.dependencies.length > 0) {
      lines.push(`**Dependencies:** ${task.dependencies.join(", ")}`);
    }
    if (task.estimate) {
      lines.push(`**Estimate:** ${task.estimate}`);
    }
    if (task.wave != null) {
      lines.push(`**Wave:** ${task.wave}`);
    }
    return lines.join("\n") || "(no description)";
  }

  _buildLabels(task) {
    const labels = [];
    if (task.priority && task.priority !== "medium") {
      labels.push(`priority:${task.priority}`);
    }
    if (task.type) {
      labels.push(task.type);
    }
    if (task.labels && task.labels.length > 0) {
      labels.push(...task.labels);
    }
    return labels;
  }

  _wrapError(err, operation) {
    const stderr = (err.stderr || err.message || "").trim();
    if (
      stderr.includes("Could not resolve") ||
      stderr.includes("not found") ||
      stderr.includes("404")
    ) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: ${operation}: ${stderr}`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      return e;
    }
    if (stderr.includes("rate limit") || stderr.includes("secondary rate")) {
      const e = new Error(
        `[${ERROR_CODES.RATE_LIMITED}] RATE_LIMITED: ${operation}: ${stderr}`,
      );
      e.code = ERROR_CODES.RATE_LIMITED;
      return e;
    }
    if (
      stderr.includes("network") ||
      stderr.includes("connect") ||
      stderr.includes("ENOTFOUND") ||
      stderr.includes("timeout")
    ) {
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: ${operation}: ${stderr}`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      return e;
    }
    const e = new Error(`${operation} failed: ${stderr || err.message}`);
    e.code = "UNKNOWN";
    return e;
  }
}

// ─── JIRA Adapter ─────────────────────────────────────────────────────────────

class JiraAdapter {
  /**
   * @param {string} baseUrl  - e.g. https://myorg.atlassian.net (falls back to JIRA_BASE_URL)
   * @param {string} email    - Jira account email (falls back to JIRA_USER_EMAIL)
   * @param {string} token    - Jira API token (falls back to JIRA_API_TOKEN)
   */
  constructor(baseUrl, email, token) {
    const resolvedUrl = baseUrl || process.env.JIRA_BASE_URL || "";
    const resolvedEmail = email || process.env.JIRA_USER_EMAIL || "";
    const resolvedToken = token || process.env.JIRA_API_TOKEN || "";
    this.baseUrl = resolvedUrl.replace(/\/$/, "");
    this.auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString(
      "base64",
    );
  }

  /**
   * Make a synchronous HTTP request to the JIRA REST API.
   * Spawns a child Node process that uses built-in fetch() and writes the
   * result as JSON to stdout. This keeps the adapter synchronous (matching
   * the GitHubAdapter contract) while satisfying the fetch() requirement.
   *
   * Credentials are forwarded via environment variables to avoid shell-quoting
   * issues with the auth token.
   *
   * @param {string} method   - HTTP method: GET, POST, PUT
   * @param {string} apiPath  - Path relative to baseUrl, e.g. /rest/api/3/issue
   * @param {object|null} body - Request body or null
   * @returns {{ status: number, retryAfter: string|null, body: any }}
   */
  _request(method, apiPath, body) {
    const url = this.baseUrl + apiPath;
    const script = `
(async () => {
  const url = process.env._JIRA_URL;
  const auth = process.env._JIRA_AUTH;
  const method = process.env._JIRA_METHOD;
  const bodyStr = process.env._JIRA_BODY;
  const opts = {
    method,
    headers: {
      Authorization: 'Basic ' + auth,
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
  };
  if (bodyStr) opts.body = bodyStr;
  let res, text;
  try {
    res = await fetch(url, opts);
    text = await res.text();
  } catch (err) {
    process.stdout.write(JSON.stringify({ __networkError: err.message }));
    process.exit(0);
  }
  let parsed;
  try { parsed = JSON.parse(text); } catch { parsed = text; }
  process.stdout.write(JSON.stringify({
    status: res.status,
    retryAfter: res.headers.get('Retry-After'),
    body: parsed,
  }));
})();
`;

    const env = Object.assign({}, process.env, {
      _JIRA_URL: url,
      _JIRA_AUTH: this.auth,
      _JIRA_METHOD: method,
      _JIRA_BODY: body ? JSON.stringify(body) : "",
    });

    let stdout;
    try {
      stdout = execFileSync(process.execPath, ["-e", script], {
        encoding: "utf-8",
        stdio: ["pipe", "pipe", "pipe"],
        env,
      });
    } catch (err) {
      const msg = (err.stderr || err.message || "").trim();
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: fetch subprocess failed: ${msg}`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      throw e;
    }

    let result;
    try {
      result = JSON.parse(stdout);
    } catch {
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: unparseable response: ${stdout.slice(0, 200)}`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      throw e;
    }

    if (result.__networkError) {
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: ${result.__networkError}`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      throw e;
    }

    return result;
  }

  /**
   * Map an HTTP status to a structured error.
   * @param {number} status
   * @param {string} operation
   * @param {any} responseBody
   * @param {string|null} retryAfter
   * @returns {Error}
   */
  _httpError(status, operation, responseBody, retryAfter) {
    const detail =
      responseBody && typeof responseBody === "object"
        ? Array.isArray(responseBody.errorMessages) &&
          responseBody.errorMessages.length
          ? responseBody.errorMessages.join("; ")
          : JSON.stringify(responseBody)
        : String(responseBody || "");

    if (status === 401 || status === 403) {
      const e = new Error(
        `[${ERROR_CODES.AUTH_FAILED}] AUTH_FAILED: ${operation}: HTTP ${status}: ${detail}`,
      );
      e.code = ERROR_CODES.AUTH_FAILED;
      return e;
    }
    if (status === 404) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: ${operation}: HTTP ${status}: ${detail}`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      return e;
    }
    if (status === 429) {
      const suffix = retryAfter ? `, retry after ${retryAfter}s` : "";
      const e = new Error(
        `[${ERROR_CODES.RATE_LIMITED}] RATE_LIMITED: ${operation}: rate limited${suffix}`,
      );
      e.code = ERROR_CODES.RATE_LIMITED;
      e.retryAfter = retryAfter;
      return e;
    }
    const e = new Error(`${operation} failed: HTTP ${status}: ${detail}`);
    e.code = "UNKNOWN";
    return e;
  }

  /**
   * Test JIRA connectivity and authentication via GET /rest/api/3/myself.
   * @returns {{ ok: boolean, error?: string }}
   */
  preflight() {
    let result;
    try {
      result = this._request("GET", "/rest/api/3/myself", null);
    } catch (err) {
      return { ok: false, error: err.message };
    }

    if (result.status === 200) {
      return { ok: true };
    }

    const err = this._httpError(
      result.status,
      "preflight",
      result.body,
      result.retryAfter,
    );
    return { ok: false, error: err.message };
  }

  /**
   * Create a new JIRA issue for the task.
   * @param {string} taskId
   * @param {object} task
   * @returns {{ number: string, url: string }}
   */
  createIssue(taskId, task) {
    const projectKey = process.env.JIRA_PROJECT_KEY;
    if (!projectKey) {
      const e = new Error(
        `[${ERROR_CODES.AUTH_FAILED}] AUTH_FAILED: createIssue: JIRA_PROJECT_KEY env var is not set`,
      );
      e.code = ERROR_CODES.AUTH_FAILED;
      throw e;
    }

    const summary = `${taskId}: ${task.title}`;
    const labels = this._buildLabels(task);
    const requestBody = {
      fields: {
        project: { key: projectKey },
        summary,
        description: this._buildDescription(task),
        issuetype: { name: "Task" },
        labels,
      },
    };

    const result = this._request("POST", "/rest/api/3/issue", requestBody);

    if (result.status === 201) {
      const key = result.body.key;
      const url = `${this.baseUrl}/browse/${key}`;
      return { number: key, url };
    }

    throw this._httpError(
      result.status,
      "createIssue",
      result.body,
      result.retryAfter,
    );
  }

  /**
   * Update summary and description of an existing JIRA issue.
   * @param {string} issueKey - e.g. "PROJ-42"
   * @param {object} task     - Current task state
   */
  updateIssue(issueKey, task) {
    const requestBody = {
      fields: {
        summary: task.title,
        description: this._buildDescription(task),
      },
    };

    const result = this._request(
      "PUT",
      `/rest/api/3/issue/${issueKey}`,
      requestBody,
    );

    // JIRA returns 204 No Content on a successful PUT /issue/{key}
    if (result.status === 204 || result.status === 200) {
      return;
    }

    throw this._httpError(
      result.status,
      "updateIssue",
      result.body,
      result.retryAfter,
    );
  }

  /**
   * Transition a JIRA issue to "Done".
   * Fetches available transitions first to find the "Done" transition ID,
   * then posts the transition.
   * @param {string} issueKey - e.g. "PROJ-42"
   */
  closeIssue(issueKey) {
    // Step 1: fetch available transitions
    const transResult = this._request(
      "GET",
      `/rest/api/3/issue/${issueKey}/transitions`,
      null,
    );

    if (transResult.status !== 200) {
      throw this._httpError(
        transResult.status,
        "closeIssue (get transitions)",
        transResult.body,
        transResult.retryAfter,
      );
    }

    const transitions = transResult.body.transitions || [];
    const doneTransition = transitions.find(
      (t) =>
        t.name === "Done" ||
        (t.to &&
          (t.to.name === "Done" ||
            (t.to.statusCategory && t.to.statusCategory.key === "done"))),
    );

    if (!doneTransition) {
      const available = transitions.map((t) => t.name).join(", ") || "(none)";
      const e = new Error(
        `closeIssue: no "Done" transition found for ${issueKey}. Available: ${available}`,
      );
      e.code = "UNKNOWN";
      throw e;
    }

    // Step 2: post the transition
    const result = this._request(
      "POST",
      `/rest/api/3/issue/${issueKey}/transitions`,
      { transition: { id: doneTransition.id } },
    );

    // JIRA returns 204 No Content on success
    if (result.status === 204 || result.status === 200) {
      return;
    }

    throw this._httpError(
      result.status,
      "closeIssue",
      result.body,
      result.retryAfter,
    );
  }

  /**
   * Fetch a single JIRA issue and return a normalised object.
   * @param {string} issueKey - e.g. "PROJ-42"
   * @returns {{ id: string, status: string, title: string, url: string }}
   */
  readIssue(issueKey) {
    const result = this._request(
      "GET",
      `/rest/api/3/issue/${issueKey}?fields=summary,status`,
      null,
    );

    if (result.status !== 200) {
      throw this._httpError(
        result.status,
        "readIssue",
        result.body,
        result.retryAfter,
      );
    }

    const fields = result.body.fields || {};
    const statusName =
      fields.status && fields.status.name
        ? fields.status.name.toLowerCase()
        : "unknown";
    // Map common JIRA status names to local vocabulary
    const statusMap = {
      done: "done",
      closed: "done",
      "in progress": "in-progress",
      "to do": "todo",
    };
    const localStatus = statusMap[statusName] || statusName;

    return {
      id: result.body.key,
      status: localStatus,
      title: fields.summary || "",
      url: `${this.baseUrl}/browse/${result.body.key}`,
    };
  }

  /**
   * Search JIRA for issues whose summary starts with the given prefix.
   * @param {string} titlePrefix
   * @returns {Array<{ id: string, title: string, url: string }>}
   */
  searchIssues(titlePrefix) {
    const projectKey = process.env.JIRA_PROJECT_KEY || "";
    const projectClause = projectKey ? ` AND project = "${projectKey}"` : "";
    const safePrefix = titlePrefix.replace(/"/g, '\\"');
    const jql = `summary ~ "${safePrefix}"${projectClause} ORDER BY created DESC`;
    const encoded = encodeURIComponent(jql);
    const result = this._request(
      "GET",
      `/rest/api/3/issue/search?jql=${encoded}&fields=summary,status&maxResults=10`,
      null,
    );

    if (result.status !== 200) {
      throw this._httpError(
        result.status,
        "searchIssues",
        result.body,
        result.retryAfter,
      );
    }

    const issues = result.body.issues || [];
    return issues.map((issue) => ({
      id: issue.key,
      title: issue.fields ? issue.fields.summary || "" : "",
      url: `${this.baseUrl}/browse/${issue.key}`,
    }));
  }

  // ─── Private helpers ───────────────────────────────────────────────────────

  _buildDescription(task) {
    const lines = [];
    if (task.description) {
      lines.push(task.description);
      lines.push("");
    }
    if (task.ac_ids && task.ac_ids.length > 0) {
      lines.push(`Acceptance Criteria: ${task.ac_ids.join(", ")}`);
    }
    if (task.dependencies && task.dependencies.length > 0) {
      lines.push(`Dependencies: ${task.dependencies.join(", ")}`);
    }
    if (task.estimate) {
      lines.push(`Estimate: ${task.estimate}`);
    }
    if (task.wave != null) {
      lines.push(`Wave: ${task.wave}`);
    }
    return lines.join("\n") || "(no description)";
  }

  _buildLabels(task) {
    const labels = [];
    if (task.priority && task.priority !== "medium") {
      labels.push(`priority:${task.priority}`);
    }
    if (task.type) {
      labels.push(task.type);
    }
    if (task.labels && task.labels.length > 0) {
      labels.push(...task.labels);
    }
    // JIRA labels must not contain spaces — replace with hyphens
    return labels.map((l) => String(l).replace(/\s+/g, "-"));
  }
}

// ─── Linear Adapter ──────────────────────────────────────────────────────────

class LinearAdapter {
  /**
   * @param {string|null} apiKey  - Linear API key (falls back to LINEAR_API_KEY env var)
   * @param {string|null} teamId  - Linear team ID (falls back to LINEAR_TEAM_ID env var)
   */
  constructor(apiKey, teamId) {
    this.apiKey = apiKey || process.env.LINEAR_API_KEY || null;
    this.teamId = teamId || process.env.LINEAR_TEAM_ID || null;
    this.endpoint = "https://api.linear.app/graphql";
  }

  /**
   * Execute a GraphQL operation against the Linear API (synchronous).
   * Uses a subprocess to run fetch so the caller does not need async/await.
   * @param {string} query - GraphQL query or mutation string
   * @param {object} [variables] - Optional variables map
   * @returns {object} - Parsed `data` field from the GraphQL response
   * @throws {Error} - Mapped to S003/S004/S006 error codes on failure
   */
  _gql(query, variables) {
    const script = `
(async () => {
  const url = process.env._LINEAR_URL;
  const apiKey = process.env._LINEAR_KEY;
  const body = process.env._LINEAR_BODY;
  let res;
  try {
    res = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: apiKey,
      },
      body,
    });
  } catch (err) {
    process.stdout.write(JSON.stringify({ __networkError: err.message }));
    process.exit(0);
  }
  let parsed;
  try { parsed = await res.json(); } catch { parsed = null; }
  process.stdout.write(JSON.stringify({
    status: res.status,
    body: parsed,
  }));
})();
`;

    const env = Object.assign({}, process.env, {
      _LINEAR_URL: this.endpoint,
      _LINEAR_KEY: this.apiKey,
      _LINEAR_BODY: JSON.stringify({ query, variables }),
    });

    let stdout;
    try {
      stdout = execFileSync(process.execPath, ["-e", script], {
        encoding: "utf-8",
        stdio: ["pipe", "pipe", "pipe"],
        env,
      });
    } catch (err) {
      const msg = (err.stderr || err.message || "").trim();
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: Linear subprocess failed: ${msg}`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      throw e;
    }

    let result;
    try {
      result = JSON.parse(stdout);
    } catch {
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: Failed to parse Linear subprocess output`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      throw e;
    }

    if (result.__networkError) {
      const e = new Error(
        `[${ERROR_CODES.NETWORK_ERROR}] NETWORK_ERROR: ${result.__networkError}`,
      );
      e.code = ERROR_CODES.NETWORK_ERROR;
      throw e;
    }

    if (result.status === 401) {
      const e = new Error(
        `[${ERROR_CODES.AUTH_FAILED}] AUTH_FAILED: Linear API key is invalid or missing`,
      );
      e.code = ERROR_CODES.AUTH_FAILED;
      throw e;
    }

    if (result.status === 429) {
      const e = new Error(
        `[${ERROR_CODES.RATE_LIMITED}] RATE_LIMITED: Linear API rate limit exceeded`,
      );
      e.code = ERROR_CODES.RATE_LIMITED;
      throw e;
    }

    const body = result.body;
    if (body && body.errors && body.errors.length > 0) {
      const msg = body.errors.map((gqlErr) => gqlErr.message).join("; ");
      const e = new Error(`Linear GraphQL error: ${msg}`);
      e.code = "UNKNOWN";
      throw e;
    }

    return body ? body.data : null;
  }

  /**
   * Build the issue description body from a task object.
   * @param {object} task
   * @returns {string}
   */
  _buildDescription(task) {
    const lines = [];
    if (task.description) {
      lines.push(task.description);
      lines.push("");
    }
    if (task.ac_ids && task.ac_ids.length > 0) {
      lines.push(`**Acceptance Criteria:** ${task.ac_ids.join(", ")}`);
    }
    if (task.dependencies && task.dependencies.length > 0) {
      lines.push(`**Dependencies:** ${task.dependencies.join(", ")}`);
    }
    if (task.estimate) {
      lines.push(`**Estimate:** ${task.estimate}`);
    }
    if (task.wave != null) {
      lines.push(`**Wave:** ${task.wave}`);
    }
    return lines.join("\n") || "(no description)";
  }

  /**
   * Test Linear API connectivity.
   * @returns {{ ok: boolean, error?: string }}
   */
  preflight() {
    try {
      const data = this._gql(`query { viewer { id name } }`);
      if (!data || !data.viewer || !data.viewer.id) {
        return {
          ok: false,
          error: `[${ERROR_CODES.AUTH_FAILED}] AUTH_FAILED: Linear viewer query returned no user`,
        };
      }
      return { ok: true };
    } catch (err) {
      return { ok: false, error: err.message };
    }
  }

  /**
   * Create a new Linear issue for the task.
   * @param {string} taskId - Local task ID used as title prefix (e.g. "T-001")
   * @param {object} task - Task object from tasks.json
   * @returns {{ number: string, url: string }}
   */
  createIssue(taskId, task) {
    const title = `${taskId}: ${task.title}`;
    const description = this._buildDescription(task);

    const mutation = `
      mutation CreateIssue($input: IssueCreateInput!) {
        issueCreate(input: $input) {
          success
          issue {
            id
            identifier
            url
          }
        }
      }
    `;

    const data = this._gql(mutation, {
      input: {
        title,
        description,
        teamId: this.teamId,
        labelIds: [],
      },
    });

    if (!data || !data.issueCreate || !data.issueCreate.issue) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: createIssue: Linear did not return an issue`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      throw e;
    }

    const issue = data.issueCreate.issue;
    return { number: issue.id, url: issue.url };
  }

  /**
   * Update an existing Linear issue's title and description.
   * @param {string} remoteId - Linear issue UUID
   * @param {object} task - Current task state from tasks.json
   * @returns {void}
   */
  updateIssue(remoteId, task) {
    const mutation = `
      mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
        issueUpdate(id: $id, input: $input) {
          success
          issue {
            id
          }
        }
      }
    `;

    const description = this._buildDescription(task);

    const data = this._gql(mutation, {
      id: remoteId,
      input: {
        title: task.title,
        description,
      },
    });

    if (!data || !data.issueUpdate || !data.issueUpdate.success) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: updateIssue: Linear update was not successful for ${remoteId}`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      throw e;
    }
  }

  /**
   * Close a Linear issue by transitioning it to the team's "completed" workflow state.
   * Queries for the Done state first, then applies it via issueUpdate.
   * @param {string} remoteId - Linear issue UUID
   * @returns {void}
   */
  closeIssue(remoteId) {
    // Query for the completed (Done) workflow state for the team
    const statesQuery = `
      query GetDoneState($teamId: ID!) {
        workflowStates(filter: {
          team: { id: { eq: $teamId } },
          type: { eq: "completed" }
        }) {
          nodes {
            id
            name
          }
        }
      }
    `;

    const statesData = this._gql(statesQuery, { teamId: this.teamId });

    const nodes =
      statesData &&
      statesData.workflowStates &&
      statesData.workflowStates.nodes;

    if (!nodes || nodes.length === 0) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: closeIssue: No completed workflow state found for team ${this.teamId}`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      throw e;
    }

    // Prefer a state named "Done", otherwise take the first completed state
    const doneState =
      nodes.find((n) => n.name.toLowerCase() === "done") || nodes[0];

    const closeMutation = `
      mutation CloseIssue($id: String!, $stateId: String!) {
        issueUpdate(id: $id, input: { stateId: $stateId }) {
          success
        }
      }
    `;

    const closeData = this._gql(closeMutation, {
      id: remoteId,
      stateId: doneState.id,
    });

    if (
      !closeData ||
      !closeData.issueUpdate ||
      !closeData.issueUpdate.success
    ) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: closeIssue: Linear close was not successful for ${remoteId}`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      throw e;
    }
  }

  /**
   * Read a Linear issue by its UUID.
   * @param {string} remoteId - Linear issue UUID
   * @returns {{ id: string, title: string, state: string, identifier: string, url: string }}
   */
  readIssue(remoteId) {
    const query = `
      query GetIssue($id: String!) {
        issue(id: $id) {
          id
          title
          state {
            name
          }
          identifier
          url
        }
      }
    `;

    const data = this._gql(query, { id: remoteId });

    if (!data || !data.issue) {
      const e = new Error(
        `[${ERROR_CODES.REMOTE_NOT_FOUND}] REMOTE_NOT_FOUND: readIssue: Issue not found: ${remoteId}`,
      );
      e.code = ERROR_CODES.REMOTE_NOT_FOUND;
      throw e;
    }

    const issue = data.issue;
    return {
      id: issue.id,
      title: issue.title,
      state: issue.state ? issue.state.name : null,
      identifier: issue.identifier,
      url: issue.url,
    };
  }

  /**
   * Search for Linear issues whose titles begin with a given prefix.
   * Used by pull-ids to match local task IDs to remote issues.
   * @param {string} titlePrefix - e.g. "T-001"
   * @returns {Array<{ id: string, title: string, identifier: string, url: string }>}
   */
  searchIssues(titlePrefix) {
    const query = `
      query SearchIssues($filter: IssueFilter!) {
        issues(filter: $filter) {
          nodes {
            id
            title
            identifier
            url
          }
        }
      }
    `;

    const data = this._gql(query, {
      filter: {
        team: { id: { eq: this.teamId } },
        title: { startsWith: titlePrefix },
      },
    });

    const nodes = data && data.issues && data.issues.nodes;
    if (!nodes) {
      return [];
    }

    return nodes.map((n) => ({
      id: n.id,
      title: n.title,
      identifier: n.identifier,
      url: n.url,
    }));
  }
}

// ─── Adapter Factory ─────────────────────────────────────────────────────────

function buildAdapter(data) {
  const { tracker_type, tracker_url } = data.sync;
  switch (tracker_type) {
    case "github":
      return new GitHubAdapter(tracker_url);
    case "jira":
      return new JiraAdapter(tracker_url, null, null);
    case "linear":
      return new LinearAdapter(null, null);
    default:
      return null;
  }
}

// ─── Changelog Helpers ────────────────────────────────────────────────────────

/**
 * Return changelog entries with seq > lastSyncSeq, optionally filtered to
 * a single task ID.
 */
function pendingEntries(data, lastSyncSeq, filterTaskId) {
  return (data.changelog || []).filter((entry) => {
    if (entry.seq <= lastSyncSeq) return false;
    if (filterTaskId && entry.task_id !== filterTaskId) return false;
    // Only task-level ops trigger sync; epic ops are not synced
    return entry.op.startsWith("task-");
  });
}

/**
 * Collapse multiple changelog entries for the same task into a net effect.
 *
 * Rules:
 *   - task-create always wins as the creation marker if present
 *   - task-transition to done/closed → closeIssue
 *   - Otherwise → createIssue (if no remote.id) or updateIssue
 *
 * Returns a Map<taskId, { action: "create"|"update"|"close", maxSeq: number }>
 */
function collapseEntries(entries, tasks) {
  const byTask = new Map();

  for (const entry of entries) {
    const { task_id, op, seq } = entry;
    const existing = byTask.get(task_id) || { ops: [], maxSeq: 0 };
    existing.ops.push(op);
    if (seq > existing.maxSeq) existing.maxSeq = seq;
    byTask.set(task_id, existing);
  }

  const result = new Map();

  for (const [taskId, info] of byTask) {
    const task = tasks[taskId];
    if (!task) continue; // task was deleted — skip

    let action;
    if (!task.remote || !task.remote.id) {
      action = "create";
    } else if (task.status === "done" || task.status === "closed") {
      action = "close";
    } else {
      action = "update";
    }

    result.set(taskId, { action, maxSeq: info.maxSeq });
  }

  return result;
}

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

/**
 * Resolve the path to features.json by walking up from cwd looking for .claude/
 */
function resolveFeaturesPath() {
  if (process.env.CLAUDE_PROJECT_DIR) {
    return path.join(
      process.env.CLAUDE_PROJECT_DIR,
      ".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) {
      return path.join(process.cwd(), ".claude", "features.json");
    }
    dir = parent;
  }
}

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

/**
 * Acquire an exclusive lock on features.json using the same spin-retry pattern
 * used for tasks.json. Returns a release function.
 */
function acquireFeaturesLock() {
  const lockPath = resolveFeaturesLockPath();
  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;

      try {
        const stat = fs.statSync(lockPath);
        if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
          try {
            fs.unlinkSync(lockPath);
          } catch {
            /* race */
          }
          continue;
        }
      } catch {
        continue;
      }

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

      // Spin-wait with jitter — see withTrackerLock above. (#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.
 */
function withFeaturesLock(fn) {
  const release = acquireFeaturesLock();
  const onExit = () => release();
  process.on("exit", onExit);
  try {
    return fn();
  } finally {
    process.removeListener("exit", onExit);
    release();
  }
}

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

function writeFeaturesFile(data) {
  const filePath = resolveFeaturesPath();
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  const tmpPath = filePath + ".tmp";
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
  fs.renameSync(tmpPath, filePath);
}

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

function cmdPreflight() {
  const data = requireTasks();
  const { tracker_type } = data.sync;

  if (!tracker_type || tracker_type === "none") {
    console.log(
      JSON.stringify({
        ok: false,
        error: `[${ERROR_CODES.SYNC_DISABLED}] SYNC_DISABLED: tracker_type is 'none'`,
      }),
    );
    process.exit(1);
  }

  const adapter = buildAdapter(data);
  if (!adapter) {
    console.log(
      JSON.stringify({
        ok: false,
        error: `Unsupported tracker type: ${tracker_type}`,
      }),
    );
    process.exit(1);
  }

  const result = adapter.preflight();

  if (result.ok) {
    withTasksLock(() => {
      const fresh = requireTasks();
      fresh.sync.preflight_passed_at = new Date().toISOString();
      fresh.sync.enabled = true;
      writeTasks(fresh);
    });
    console.log(JSON.stringify({ ok: true, tracker_type }));
    process.exit(0);
  } else {
    console.log(
      JSON.stringify({ ok: false, error: result.error, tracker_type }),
    );
    process.exit(1);
  }
}

function cmdPush(args) {
  const filterTaskId = args[0] || null;

  withTasksLock(() => {
    const data = requireTasks();

    if (!data.sync.enabled) {
      console.error(
        `[${ERROR_CODES.SYNC_DISABLED}] SYNC_DISABLED: Sync is not enabled. Run 'preflight' first.`,
      );
      process.exit(1);
    }

    const adapter = buildAdapter(data);
    if (!adapter) {
      console.error(`Unsupported tracker type: ${data.sync.tracker_type}`);
      process.exit(1);
    }

    const lastSyncSeq = data.sync.last_sync_seq || 0;
    const entries = pendingEntries(data, lastSyncSeq, filterTaskId);

    if (entries.length === 0) {
      console.log(
        JSON.stringify({
          synced: 0,
          failed: 0,
          failures: [],
          last_sync_seq: lastSyncSeq,
        }),
      );
      process.exit(0);
    }

    const netEffects = collapseEntries(entries, data.tasks);

    let synced = 0;
    let failed = 0;
    const failures = [];
    let newLastSyncSeq = lastSyncSeq;

    for (const [taskId, effect] of netEffects) {
      const task = data.tasks[taskId];
      if (!task) continue;

      try {
        const now = new Date().toISOString();

        if (effect.action === "create") {
          const { number, url } = adapter.createIssue(taskId, task);
          task.remote.id = String(number);
          task.remote.url = url;
          task.remote.synced_at = now;
          task.remote.synced_seq = effect.maxSeq;
          task.remote.status = "synced";
        } else if (effect.action === "close") {
          // If we somehow have no remote ID yet, create first then close
          if (!task.remote.id) {
            const { number, url } = adapter.createIssue(taskId, task);
            task.remote.id = String(number);
            task.remote.url = url;
          }
          adapter.closeIssue(task.remote.id);
          task.remote.synced_at = now;
          task.remote.synced_seq = effect.maxSeq;
          task.remote.status = "synced";
        } else {
          // update
          adapter.updateIssue(task.remote.id, task);
          task.remote.synced_at = now;
          task.remote.synced_seq = effect.maxSeq;
          task.remote.status = "synced";
        }

        if (effect.maxSeq > newLastSyncSeq) {
          newLastSyncSeq = effect.maxSeq;
        }
        synced++;
      } catch (err) {
        failed++;
        failures.push({ task_id: taskId, error: err.message });
        task.remote.status = "error";
      }
    }

    // Advance last_sync_seq to the highest seq we successfully processed.
    // If all succeeded, this equals the max seq across all entries.
    // If some failed, we only advance past fully-processed seqs.
    data.sync.last_sync_seq = newLastSyncSeq;
    if (synced > 0) {
      data.sync.last_sync_at = new Date().toISOString();
    }

    // Prune changelog entries that are now at or below last_sync_seq
    // (only when there are no failures — with failures we keep entries for retry)
    if (failed === 0) {
      data.changelog = (data.changelog || []).filter(
        (entry) => entry.seq > newLastSyncSeq,
      );
    }

    writeTasks(data);

    const output = {
      synced,
      failed,
      failures,
      last_sync_seq: newLastSyncSeq,
    };

    console.log(JSON.stringify(output));
    process.exit(failed > 0 ? 1 : 0);
  });
}

function cmdStatus() {
  const data = requireTasks();
  const sync = data.sync || {};
  const tasks = data.tasks || {};

  const lastSyncSeq = sync.last_sync_seq || 0;
  const pendingCount = pendingEntries(data, lastSyncSeq, null).length;

  let totalTasks = 0;
  let syncedTasks = 0;
  let errorCount = 0;

  for (const task of Object.values(tasks)) {
    totalTasks++;
    if (task.remote && task.remote.status === "synced") {
      syncedTasks++;
    }
    if (task.remote && task.remote.status === "error") {
      errorCount++;
    }
  }

  console.log(
    JSON.stringify({
      tracker_type: sync.tracker_type || "none",
      enabled: sync.enabled || false,
      last_sync_at: sync.last_sync_at || null,
      pending_count: pendingCount,
      error_count: errorCount,
      total_tasks: totalTasks,
      synced_tasks: syncedTasks,
    }),
  );
  process.exit(0);
}

// ─── reconcile (AC-22, AC-27) ─────────────────────────────────────────────────

/**
 * Fetch all remote issues linked in tasks.json, compare local vs remote state,
 * and report drift. With --dry-run, no writes are made (output only).
 *
 * Output shape:
 *   { in_sync, local_ahead, remote_ahead, both_changed, local_only, remote_only, drift[] }
 */
function cmdReconcile(args) {
  const dryRun = args.includes("--dry-run");

  const data = requireTasks();

  if (!data.sync.enabled) {
    console.error(
      `[${ERROR_CODES.SYNC_DISABLED}] SYNC_DISABLED: Sync is not enabled. Run 'preflight' first.`,
    );
    process.exit(1);
  }

  const adapter = buildAdapter(data);
  if (!adapter) {
    console.error(`Unsupported tracker type: ${data.sync.tracker_type}`);
    process.exit(1);
  }

  if (typeof adapter.readIssue !== "function") {
    console.error(
      `readIssue not supported by adapter: ${data.sync.tracker_type}`,
    );
    process.exit(1);
  }

  const tasks = data.tasks || {};
  const linkedTasks = Object.entries(tasks).filter(
    ([, task]) => task.remote && task.remote.id,
  );

  let inSync = 0;
  let localAhead = 0;
  let remoteAhead = 0;
  let bothChanged = 0;
  let localOnly = 0;
  const drift = [];

  // Tasks with no remote link
  for (const [, task] of Object.entries(tasks)) {
    if (!task.remote || !task.remote.id) {
      localOnly++;
    }
  }

  for (const [taskId, task] of linkedTasks) {
    let remote;
    try {
      remote = adapter.readIssue(task.remote.id);
    } catch (err) {
      // If the remote issue is not found, treat as diverged
      drift.push({
        task_id: taskId,
        type: "remote_not_found",
        field: "id",
        local: task.remote.id,
        remote: null,
        error: err.message,
      });
      remoteAhead++;
      continue;
    }

    // Compare status: normalise local status to match remote vocabulary
    const localStatus = task.status;
    const remoteStatus = remote.status;

    // Determine whether local or remote has changed since last sync
    const lastSyncedSeq = task.remote.synced_seq || 0;
    const lastSyncSeq = data.sync.last_sync_seq || 0;
    const localChanged =
      lastSyncedSeq < lastSyncSeq ||
      (data.changelog || []).some(
        (e) => e.task_id === taskId && e.seq > lastSyncedSeq,
      );
    const remoteChanged = remoteStatus !== localStatus;

    if (!remoteChanged && !localChanged) {
      inSync++;
    } else if (localChanged && remoteChanged) {
      bothChanged++;
      drift.push({
        task_id: taskId,
        type: "both_changed",
        field: "status",
        local: localStatus,
        remote: remoteStatus,
      });
    } else if (remoteChanged) {
      remoteAhead++;
      drift.push({
        task_id: taskId,
        type: "remote_ahead",
        field: "status",
        local: localStatus,
        remote: remoteStatus,
      });
    } else {
      localAhead++;
      drift.push({
        task_id: taskId,
        type: "local_ahead",
        field: "status",
        local: localStatus,
        remote: remoteStatus,
      });
    }
  }

  const result = {
    in_sync: inSync,
    local_ahead: localAhead,
    remote_ahead: remoteAhead,
    both_changed: bothChanged,
    local_only: localOnly,
    remote_only: 0, // GitHub-side issues not in local tasks.json — not enumerable without full list fetch
    drift,
    dry_run: dryRun,
  };

  console.log(JSON.stringify(result, null, 2));
  process.exit(0);
}

// ─── pull-status (AC-26) ──────────────────────────────────────────────────────

/**
 * Remote-first status fetch: for each task with a remote.id, fetch the remote
 * status and update local task.status if it differs. Runs at task-start to
 * ensure local state reflects remote.
 *
 * Output: { "updated": N, "unchanged": N }
 */
function cmdPullStatus() {
  withTasksLock(() => {
    const data = requireTasks();

    if (!data.sync.enabled) {
      console.error(
        `[${ERROR_CODES.SYNC_DISABLED}] SYNC_DISABLED: Sync is not enabled. Run 'preflight' first.`,
      );
      process.exit(1);
    }

    const adapter = buildAdapter(data);
    if (!adapter) {
      console.error(`Unsupported tracker type: ${data.sync.tracker_type}`);
      process.exit(1);
    }

    if (typeof adapter.readIssue !== "function") {
      console.error(
        `readIssue not supported by adapter: ${data.sync.tracker_type}`,
      );
      process.exit(1);
    }

    const tasks = data.tasks || {};
    let updated = 0;
    let unchanged = 0;

    for (const [taskId, task] of Object.entries(tasks)) {
      if (!task.remote || !task.remote.id) continue;

      let remote;
      try {
        remote = adapter.readIssue(task.remote.id);
      } catch {
        // Skip tasks where remote fetch fails — leave local state unchanged
        unchanged++;
        continue;
      }

      if (remote.status !== task.status) {
        task.status = remote.status;
        task.remote.synced_at = new Date().toISOString();
        updated++;
      } else {
        unchanged++;
      }
    }

    if (updated > 0) {
      writeTasks(data);
    }

    console.log(JSON.stringify({ updated, unchanged }));
    process.exit(0);
  });
}

// ─── pull-ids (AC-27) ────────────────────────────────────────────────────────

/**
 * One-time operation: search the remote tracker for issues matching task titles
 * and link them by storing the remote ID in task.remote.id.
 *
 * Matching strategy: search by task ID prefix (e.g. "T-001:") in the issue title.
 *
 * Output: { "linked": N, "unmatched": N }
 */
function cmdPullIds() {
  withTasksLock(() => {
    const data = requireTasks();

    if (!data.sync.enabled) {
      console.error(
        `[${ERROR_CODES.SYNC_DISABLED}] SYNC_DISABLED: Sync is not enabled. Run 'preflight' first.`,
      );
      process.exit(1);
    }

    const adapter = buildAdapter(data);
    if (!adapter) {
      console.error(`Unsupported tracker type: ${data.sync.tracker_type}`);
      process.exit(1);
    }

    if (typeof adapter.searchIssues !== "function") {
      console.error(
        `searchIssues not supported by adapter: ${data.sync.tracker_type}`,
      );
      process.exit(1);
    }

    const tasks = data.tasks || {};
    let linked = 0;
    let unmatched = 0;

    for (const [taskId, task] of Object.entries(tasks)) {
      // Skip tasks already linked
      if (task.remote && task.remote.id) continue;

      let results;
      try {
        results = adapter.searchIssues(taskId + ":");
      } catch {
        unmatched++;
        continue;
      }

      // Find best match: issue title starts with "taskId: " (case-insensitive)
      const prefix = (taskId + ":").toLowerCase();
      const match = results.find((r) =>
        r.title.toLowerCase().startsWith(prefix),
      );

      if (match) {
        if (!task.remote) task.remote = {};
        task.remote.id = match.id;
        task.remote.url = match.url;
        task.remote.synced_at = new Date().toISOString();
        task.remote.status = "linked";
        linked++;
      } else {
        unmatched++;
      }
    }

    if (linked > 0) {
      writeTasks(data);
    }

    console.log(JSON.stringify({ linked, unmatched }));
    process.exit(0);
  });
}

// ─── push-feature (AC-12) ────────────────────────────────────────────────────

/**
 * Create a remote tracker issue for a feature and update features.json with
 * the remote reference.
 *
 * Usage: push-feature <KEY>-N
 *
 * Reads tracker config from tasks.json (sync.tracker_type / sync.tracker_url).
 * Reads the feature from features.json directly.
 * Skips features already in sync (remote.status === "synced").
 *
 * Output: { "id": "<KEY>-N", "remote": { tracker, issue_number, url, synced_at, status } }
 */
function cmdPushFeature(args) {
  const featureId = args[0];
  if (!featureId) {
    console.error("Usage: push-feature <<KEY>-N>");
    process.exit(1);
  }

  // Normalise to uppercase (e.g. "feat-74" → "FEAT-74")
  // Accept whatever is passed and let features.json validate it
  const id = featureId.toUpperCase();

  // ── 1. Resolve tracker type from tasks.json ──────────────────────────────
  const tasksData = readTasks();
  const trackerType =
    tasksData && tasksData.sync && tasksData.sync.tracker_type
      ? tasksData.sync.tracker_type
      : "none";

  if (!trackerType || trackerType === "none" || trackerType === "Local") {
    console.error(
      `[${ERROR_CODES.SYNC_DISABLED}] SYNC_DISABLED: No tracker configured (tracker_type is '${trackerType}'). Run 'sync-tracker.js preflight' to enable sync.`,
    );
    process.exit(1);
  }

  // ── 2. Build adapter ──────────────────────────────────────────────────────
  const adapter = tasksData ? buildAdapter(tasksData) : null;
  if (!adapter) {
    console.error(`Unsupported tracker type: ${trackerType}`);
    process.exit(1);
  }

  // ── 3. Read feature ───────────────────────────────────────────────────────
  const featuresData = readFeaturesFile();
  if (!featuresData) {
    console.error(
      `[E002] FEATURE_NOT_FOUND: features.json does not exist. Create features first.`,
    );
    process.exit(1);
  }

  const feature = featuresData.features && featuresData.features[id];
  if (!feature) {
    console.error(`[E002] FEATURE_NOT_FOUND: Feature not found: ${id}`);
    process.exit(1);
  }

  // ── 4. Skip if already synced ─────────────────────────────────────────────
  if (feature.remote && feature.remote.status === "synced") {
    console.log(JSON.stringify({ id, remote: feature.remote }));
    process.exit(0);
  }

  // ── 5. Create remote issue ────────────────────────────────────────────────
  // Adapt feature shape to the task-like object expected by adapter.createIssue
  const issueSubject = {
    title: feature.display_name || id,
    description: feature.description || null,
  };

  let issueNumber, issueUrl;
  try {
    const result = adapter.createIssue(id, issueSubject);
    issueNumber = result.number;
    issueUrl = result.url;
  } catch (err) {
    console.error(`push-feature failed: ${err.message}`);
    process.exit(1);
  }

  // ── 6. Persist remote reference in features.json ─────────────────────────
  const remote = {
    tracker: trackerType,
    issue_number: issueNumber,
    url: issueUrl,
    synced_at: new Date().toISOString(),
    status: "synced",
  };

  try {
    withFeaturesLock(() => {
      // Re-read inside lock to avoid races
      const fresh = readFeaturesFile();
      if (!fresh || !fresh.features || !fresh.features[id]) {
        throw new Error(
          `[E002] FEATURE_NOT_FOUND: Feature ${id} disappeared before write — aborting.`,
        );
      }
      fresh.features[id].remote = remote;
      writeFeaturesFile(fresh);
    });
  } catch (err) {
    console.error(err.message);
    process.exit(1);
  }

  console.log(JSON.stringify({ id, remote }));
  process.exit(0);
}

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

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

  switch (command) {
    case "preflight":
      cmdPreflight();
      break;
    case "push":
      cmdPush(rest);
      break;
    case "status":
      cmdStatus();
      break;
    case "reconcile":
      cmdReconcile(rest);
      break;
    case "pull-status":
      cmdPullStatus();
      break;
    case "pull-ids":
      cmdPullIds();
      break;
    case "push-feature":
      cmdPushFeature(rest);
      break;
    default:
      console.error(
        "Usage: sync-tracker.js <preflight|push [task-id]|status|reconcile [--dry-run]|pull-status|pull-ids|push-feature <KEY>-N>",
      );
      process.exit(1);
  }
}

main();
