import { dirname, join } from "node:path";
import { createHash } from "node:crypto";
import {
  existsSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  rmSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from "node:fs";
import { simpleGit, type SimpleGit } from "simple-git";
import type { Logger } from "pino";
import { RepoOperationError } from "@/core/errors.js";
import type { BuildValidationResult, RepoConfig, RepoHandle, SafetyReport } from "@/types/index.js";
import { SAFETY, CLONE } from "@/core/constants.js";
import { detectPackageManager } from "@/utils/package-manager.js";
import { resolveNodeBinPath } from "@/utils/node-version.js";
import { redactGitRemoteUrl } from "@/utils/git-remote.js";

const isLockfile = (filePath: string) => SAFETY.LOCK_FILES.some((p) => filePath.endsWith(p));
const isBotManagedPath = (filePath: string) =>
  filePath.startsWith(".cursor/rules/") ||
  filePath === ".cursor/rules" ||
  filePath.startsWith(".hu-bot/") ||
  filePath === ".hu-bot";
const INSTALL_INPUT_PATHS = new Set(["package.json", ".yarnrc.yml", ...SAFETY.LOCK_FILES]);
const isInstallInput = (filePath: string) =>
  INSTALL_INPUT_PATHS.has(filePath) ||
  filePath.startsWith(".yarn/patches/") ||
  filePath.startsWith("patches/");

const GIT_EXCLUDE_PATTERNS = ["/core.[0-9]*"];

function ensureGitExclude(repoPath: string): void {
  const excludePath = join(repoPath, ".git", "info", "exclude");
  const dir = dirname(excludePath);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });

  const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf-8") : "";
  const lines = new Set(existing.split("\n").map((l) => l.trim()));
  const missing = GIT_EXCLUDE_PATTERNS.filter((p) => !lines.has(p));
  if (missing.length > 0) {
    writeFileSync(excludePath, `${existing.trimEnd()}\n${missing.join("\n")}\n`);
  }
}

export async function configureGitAuth(
  getToken: () => Promise<string>,
  logger: Logger,
): Promise<void> {
  const token = await getToken();
  const git = simpleGit();

  // Clear any previous x-access-token insteadOf entries to avoid stale tokens
  // (GitHub App installation tokens expire after ~1 hour).
  try {
    const raw = await git.raw([
      "config",
      "--global",
      "--get-regexp",
      "url\\.https://x-access-token:.*@github\\.com/\\.insteadOf",
    ]);
    const keys = new Set<string>();
    for (const line of raw.trim().split("\n").filter(Boolean)) {
      const key = line.split(/\s/)[0];
      if (key) keys.add(key);
    }
    for (const key of keys) {
      await git.raw(["config", "--global", "--unset-all", key]);
    }
  } catch {
    // No existing entries found — first run
  }

  // Rewrite SSH and bare HTTPS GitHub URLs to HTTPS with token. Without the
  // https://github.com/ → token URL rule, remotes stored as https://github.com/...
  // (e.g. after a manual set-url) push without credentials and GitHub returns
  // "Invalid username or token".
  const rewriteUrl = "url.https://x-access-token:" + token + "@github.com/.insteadOf";
  await git.addConfig(rewriteUrl, "git@github.com:", false, "global");
  await git.addConfig(rewriteUrl, "ssh://git@github.com/", true, "global");
  await git.addConfig(rewriteUrl, "https://github.com/", true, "global");
  logger.info("git_auth_configured");
}

export async function syncAllRepos(
  repos: readonly RepoConfig[],
  workdirPath: string,
  branchName: string,
  logger: Logger,
  getToken?: () => Promise<string>,
): Promise<RepoHandle[]> {
  if (getToken) {
    await configureGitAuth(getToken, logger);
  }

  const results = await Promise.allSettled(
    repos.map(async (repo) => {
      const localPath = join(workdirPath, repo.name);
      const repoLogger = logger.child({ repo: repo.name });
      const manager = new RepoManagerImpl(repo.url, localPath, repoLogger, repo.installCmd);

      await manager.ensureCloned();
      const baseBranch = repo.defaultBranch ?? "develop";
      await manager.cleanStaleBranches(baseBranch);
      await manager.checkoutBase(baseBranch);
      await manager.createBranch(branchName);

      try {
        if (await manager.needsInstall()) {
          await manager.installDependencies();
        } else {
          repoLogger.info("install_skipped_unchanged");
        }
      } catch (err) {
        repoLogger.warn(
          { error: err instanceof Error ? err.message : String(err) },
          "install_failed_non_blocking",
        );
      }

      return { repo, manager } satisfies RepoHandle;
    }),
  );

  const handles: RepoHandle[] = [];
  for (let i = 0; i < results.length; i++) {
    const result = results[i]!;
    if (result.status === "fulfilled") {
      handles.push(result.value);
    } else {
      const repo = repos[i]!;
      const reason = result.reason;
      const message = reason instanceof Error ? reason.message : String(reason);
      const cause =
        reason instanceof Error && reason.cause instanceof Error ? reason.cause.message : undefined;
      logger
        .child({ repo: repo.name })
        .warn({ error: message, cause }, "repo_sync_failed_skipping");
    }
  }

  logger.info(
    { synced: handles.length, total: repos.length, repos: handles.map((h) => h.repo.name) },
    "all_repos_synced",
  );

  return handles;
}

/**
 * Boot-time warmup: full clone + checkout base for every repo, in parallel,
 * with NO ticket branch and NO dependency install. Fail-open — a repo that can't
 * be reached is logged and skipped, never throws — and bounded by `timeoutMs` so a
 * hung clone cannot wedge the deploy. Returns the number of repos warmed.
 */
export async function prewarmRepos(
  repos: readonly RepoConfig[],
  workdirPath: string,
  logger: Logger,
  getToken?: () => Promise<string>,
  timeoutMs: number = CLONE.PREWARM_TIMEOUT_MS,
): Promise<number> {
  const startedAt = Date.now();
  if (getToken) {
    await configureGitAuth(getToken, logger);
  }

  // Each repo logs its own success/failure (with duration) so the loop below
  // just needs to count fulfilled vs rejected.
  const warmOne = async (repo: RepoConfig): Promise<void> => {
    const localPath = join(workdirPath, repo.name);
    const repoLogger = logger.child({ repo: repo.name });
    const manager = new RepoManagerImpl(repo.url, localPath, repoLogger, repo.installCmd);
    const repoStart = Date.now();
    try {
      await manager.ensureCloned();
      await manager.checkoutBase(repo.defaultBranch ?? "develop");
      repoLogger.info({ durationMs: Date.now() - repoStart }, "prewarm_repo_warmed");
    } catch (err) {
      repoLogger.warn(
        {
          error: err instanceof Error ? err.message : String(err),
          durationMs: Date.now() - repoStart,
        },
        "prewarm_repo_failed_skipping",
      );
      throw err; // keep the rejection so allSettled does not count this repo as warmed
    }
  };

  const work = Promise.allSettled(repos.map(warmOne));
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<"timeout">((resolve) => {
    timer = setTimeout(() => resolve("timeout"), timeoutMs);
  });

  const raced = await Promise.race([work, timeout]);
  clearTimeout(timer);
  if (raced === "timeout") {
    logger.warn(
      { total: repos.length, timeoutMs, durationMs: Date.now() - startedAt },
      "prewarm_timed_out_marking_ready",
    );
    return 0;
  }

  const warmed = raced.filter((r) => r.status === "fulfilled").length;
  logger.info(
    { warmed, total: repos.length, durationMs: Date.now() - startedAt },
    "prewarm_complete",
  );
  return warmed;
}

export class RepoManagerImpl {
  private git: SimpleGit | null = null;
  private nodeBinPath: string | null = null;

  constructor(
    private readonly repoUrl: string,
    private readonly localPath: string,
    private readonly logger: Logger,
    private readonly installCmd?: string[],
  ) {}

  async ensureCloned(): Promise<void> {
    const { mkdirSync } = await import("node:fs");

    if (existsSync(join(this.localPath, ".git"))) {
      this.git = simpleGit(this.localPath);
      this.resolveNodeEnv();
      this.logger.info({ path: this.localPath }, "repo_ready");
      return;
    }

    try {
      this.logger.info({ url: this.repoUrl, path: this.localPath }, "Cloning repository");
      mkdirSync(this.localPath, { recursive: true });

      const base = simpleGit();
      await base.clone(this.repoUrl, this.localPath);
      this.git = simpleGit(this.localPath);
      this.resolveNodeEnv();

      this.logger.info({ path: this.localPath }, "repo_cloned");
    } catch (err) {
      throw new RepoOperationError("Failed to clone repository", {
        cause: err,
      });
    }
  }

  private resolveNodeEnv(): void {
    const result = resolveNodeBinPath(this.localPath);
    if (result) {
      this.nodeBinPath = result.binPath;
      this.logger.info(
        { requested: result.requested, resolved: result.resolved, binPath: result.binPath },
        "node_version_resolved",
      );
    }
  }

  async checkoutBranch(branchName: string): Promise<void> {
    const git = this.getGit();

    try {
      await git.reset(["--hard"]);
      await git.fetch("origin", branchName);
      await git.checkout(["-B", branchName, `origin/${branchName}`]);
      this.logger.info({ branch: branchName }, "branch_checked_out");
    } catch (err) {
      // Mirror cleanStaleBranches: a mid-operation failure can leave a .lock
      // behind; clear it so the next attempt isn't wedged on a stale lock.
      this.clearGitLocks();
      const hint = err instanceof Error && err.message ? ` (${err.message})` : "";
      throw new RepoOperationError(`Failed to checkout branch ${branchName}${hint}`, {
        cause: err,
      });
    }
  }

  async getChangedFiles(baseBranch: string): Promise<string[]> {
    const git = this.getGit();
    try {
      const baseRef = `origin/${baseBranch}`;
      const [committed, workingTree, status] = await Promise.all([
        git.raw(["diff", "--name-only", `${baseRef}...HEAD`]),
        git.raw(["diff", "--name-only", baseRef]),
        git.status(),
      ]);

      const files = new Set<string>();
      for (const src of [committed, workingTree]) {
        for (const line of src.trim().split("\n")) {
          if (line && !isLockfile(line) && !isBotManagedPath(line)) files.add(line);
        }
      }
      for (const f of status.not_added) {
        if (!isLockfile(f) && !isBotManagedPath(f)) files.add(f);
      }

      return Array.from(files);
    } catch (err) {
      // Fail-soft (return []), but make the swallow observable: a silent [] here
      // can make commitAndPush skip the push ("no meaningful changes") on what is
      // really a transient git error. Log at error so it surfaces in Datadog.
      this.logger.error({ err, baseBranch }, "get_changed_files_failed");
      return [];
    }
  }

  private hasErrorsInChangedFiles(output: string, changedFiles: string[]): boolean {
    const set = new Set(changedFiles);
    // tsc: path/file.ts(71,14): or path/file.ts:71:14
    const tscPattern = /([^\s(]+\.(?:ts|tsx|js|jsx|mjs|cjs))(?:\s*\((\d+),(\d+)\)|:(\d+):(\d+))/g;
    // eslint: path at start of line or after "  " (common in eslint output)
    const pathLike = /(?:^|\s)([a-zA-Z0-9_./-]+\.(?:ts|tsx|js|jsx|mjs|cjs))(?:[\s:]|$)/gm;
    const seen = new Set<string>();

    for (const re of [tscPattern, pathLike]) {
      let m: RegExpExecArray | null;
      re.lastIndex = 0;
      while ((m = re.exec(output)) !== null) {
        const path = m[1]!.trim().replace(/^\.\//, "");
        if (path && !path.includes("node_modules") && !seen.has(path)) {
          seen.add(path);
          if (set.has(path)) return true;
          // check normalized (no leading ./)
          const normalized = path.startsWith("./") ? path.slice(2) : path;
          if (set.has(normalized)) return true;
        }
      }
    }
    return false;
  }

  async validateBuild(
    checkScripts?: string[],
    baseBranch?: string,
  ): Promise<BuildValidationResult> {
    const result: BuildValidationResult = { success: true };

    this.logger.info("build_started");

    const pm = detectPackageManager(this.localPath, this.installCmd);
    const nodeModulesExist = existsSync(join(this.localPath, "node_modules"));
    const installInputsChanged = await this.hasInstallInputsChanged();

    if (!nodeModulesExist || installInputsChanged) {
      const installResult = await this.runCommand(pm.cmd, pm.installArgs);
      result.installOutput = installResult.output;
      if (!installResult.success) {
        return {
          success: false,
          installOutput: installResult.output,
          failedStep: "install",
        };
      }
    }

    const scripts = checkScripts?.length ? checkScripts : this.defaultCheckScripts();
    result.checkOutputs = {};
    const changedFiles = baseBranch ? await this.getChangedFiles(baseBranch) : null;

    for (const script of scripts) {
      if (!this.hasScript(script)) {
        this.logger.info({ script }, "check_script_not_found_skipping");
        continue;
      }

      this.logger.info({ script }, "running_check_script");
      const checkResult = await this.runCommand(pm.cmd, ["run", script]);
      result.checkOutputs[script] = checkResult.output;

      if (!checkResult.success) {
        if (checkResult.exitCode === 127) {
          this.logger.warn(
            { script, exitCode: 127, output: checkResult.output.slice(-300) },
            "check_binary_not_available_skipping",
          );
          continue;
        }
        if (
          changedFiles &&
          changedFiles.length > 0 &&
          !this.hasErrorsInChangedFiles(checkResult.output, changedFiles)
        ) {
          this.logger.info({ script }, "check_errors_preexisting_skipping");
          continue;
        }
        return {
          ...result,
          success: false,
          failedStep: "check",
          failedCheck: script,
        };
      }
    }

    this.logger.info("build_passed");
    return result;
  }

  private defaultCheckScripts(): string[] {
    const candidates = ["build"];
    return candidates.filter((s) => this.hasScript(s));
  }

  private clearGitLocks(): void {
    const gitDir = join(this.localPath, ".git");
    const dirsToScan = [join(gitDir, "refs", "remotes"), gitDir];
    let removed = 0;
    for (const dir of dirsToScan) {
      try {
        const entries = readdirSync(dir, { recursive: true, withFileTypes: true });
        for (const entry of entries) {
          if (entry.isFile() && entry.name.endsWith(".lock")) {
            const lockPath = join(entry.parentPath, entry.name);
            try {
              unlinkSync(lockPath);
              removed++;
            } catch {
              // ignore — another process may have already removed it
            }
          }
        }
      } catch {
        // dir may not exist
      }
    }
    if (removed > 0) {
      this.logger.warn({ removed }, "git_lock_files_cleared");
    }
  }

  async cleanStaleBranches(baseBranch: string): Promise<void> {
    const git = this.getGit();
    try {
      await git.reset(["--hard"]);
      await git.clean("f", ["-d"]);
      await git.checkout(baseBranch);
      const branches = await git.branchLocal();
      const toDelete = branches.all.filter((b) => b !== branches.current);
      for (const b of toDelete) {
        await git.branch(["-D", b]);
      }
      this.clearGitLocks();
      if (toDelete.length > 0) {
        this.logger.info(
          { deleted: toDelete.length, branches: toDelete },
          "stale_branches_cleaned",
        );
      }
    } catch (err) {
      this.logger.warn(
        { error: err instanceof Error ? err.message : String(err) },
        "clean_stale_branches_error",
      );
      throw new RepoOperationError(`Failed to clean stale branches`, { cause: err });
    }

    try {
      await git.fetch(["--prune"]);
    } catch (err) {
      this.logger.warn(
        { error: err instanceof Error ? err.message : String(err) },
        "fetch_prune_failed_non_blocking",
      );
    }
  }

  async checkoutBase(branch: string): Promise<void> {
    const git = this.getGit();
    try {
      await git.checkout(["--", "."]);
      await git.clean("f", ["-d"]);
      await git.checkout(branch);
      await git.fetch("origin", branch);
      await git.reset(["--hard", `origin/${branch}`]);
      this.logger.info({ branch }, "checked_out_base");
    } catch (err) {
      throw new RepoOperationError(`Failed to checkout ${branch}`, { cause: err });
    }
  }

  async createBranch(branchName: string): Promise<void> {
    const git = this.getGit();
    try {
      const branches = await git.branchLocal();
      if (branches.all.includes(branchName)) {
        await git.branch(["-D", branchName]);
        this.logger.info({ branch: branchName }, "deleted_existing_local_branch");
      }

      // Also remove the remote branch so the pipeline starts from a clean
      // slate and the later --force push doesn't silently overwrite prior work.
      try {
        await git.raw(["ls-remote", "--exit-code", "--heads", "origin", branchName]);
        // ls-remote exits 0 when the ref exists → delete it
        await git.push("origin", branchName, ["--delete"]);
        this.logger.info({ branch: branchName }, "deleted_existing_remote_branch");
      } catch {
        // ls-remote exits 2 when the ref is missing — nothing to delete
      }

      await git.checkoutLocalBranch(branchName);
      this.logger.info({ branch: branchName }, "branch_created");
    } catch (err) {
      throw new RepoOperationError(`Failed to create branch ${branchName}`, { cause: err });
    }
  }

  async commitAndPush(branchName: string, message: string, baseBranch?: string): Promise<void> {
    const git = this.getGit();
    this.logger.info({ branch: branchName }, "commit_push_started");

    // ── Stage & commit ──────────────────────────────────────────────────────
    try {
      try {
        ensureGitExclude(this.localPath);
      } catch {
        // best-effort — don't let exclude failure block core dump cleanup or commit
      }

      try {
        const entries = readdirSync(this.localPath);
        const coreDumps = entries.filter((e) => e === "core" || /^core\.\d+$/.test(e));
        for (const f of coreDumps) {
          const fullPath = join(this.localPath, f);
          const stat = statSync(fullPath);
          if (!stat.isFile()) continue;
          this.logger.warn(
            { file: f, sizeMB: Math.round(stat.size / 1024 / 1024) },
            "core_dump_detected_and_removed",
          );
          rmSync(fullPath);
        }
      } catch {
        // best-effort cleanup
      }

      this.logger.info({ branch: branchName }, "staging_changes");
      await git.add("-A");
      const staged = await git.status();
      this.logger.info(
        { branch: branchName, stagedFiles: staged.files.length },
        "staging_completed",
      );

      // Unstage files that should never be committed: lockfiles (side-effect)
      // and .cursor/rules/ symlinks created by syncRemoteRulesRepos.
      const excludeFromCommit = staged.files.filter(
        (f) => isLockfile(f.path) || isBotManagedPath(f.path),
      );
      const intentionalFiles = staged.files.filter(
        (f) => !isLockfile(f.path) && !isBotManagedPath(f.path),
      );

      // Only strip lockfiles when there are also non-lockfile/non-rules changes.
      // When the lockfile IS the only change, keep it: the task was explicitly a
      // dependency/lockfile update. Rules sync files are always excluded.
      const rulesFiles = excludeFromCommit.filter((f) => isBotManagedPath(f.path));
      const lockFiles = excludeFromCommit.filter((f) => isLockfile(f.path));
      const lockfilesAreOnlyChange =
        lockFiles.length > 0 && intentionalFiles.length === 0 && rulesFiles.length === 0;

      const toUnstage = [...rulesFiles, ...(lockfilesAreOnlyChange ? [] : lockFiles)];
      for (const f of toUnstage) {
        await git.raw(["reset", "HEAD", f.path]);
        try {
          await git.checkout(["--", f.path]);
        } catch {
          // Symlinks or new files may not have a base to checkout — just unstage.
        }
        this.logger.info({ file: f.path }, "file_excluded_from_commit");
      }

      const finalStatus = await git.status();
      if (finalStatus.files.length > 0) {
        this.logger.info({ branch: branchName, files: finalStatus.files.length }, "committing");
        await git.commit(message);
        this.logger.info({ branch: branchName, files: finalStatus.files.length }, "committed");
      } else if (baseBranch) {
        const meaningfulFiles = await this.getChangedFiles(baseBranch);
        if (meaningfulFiles.length === 0) {
          this.logger.info({ branch: branchName }, "skip_push_no_meaningful_changes");
          return;
        }
        this.logger.info({ branch: branchName }, "no_uncommitted_changes_pushing_existing_commits");
      } else {
        this.logger.info({ branch: branchName }, "no_uncommitted_changes_pushing_existing_commits");
      }
    } catch (err) {
      const detail = err instanceof Error ? err.message : String(err);
      this.logger.error(
        { branch: branchName, step: "commit", error: detail },
        "commit_push_failed",
      );
      throw new RepoOperationError(`Failed to commit: ${detail}`, { cause: err });
    }

    // ── Push ────────────────────────────────────────────────────────────────
    try {
      const remotes = await git.getRemotes(true);
      const originUrl = remotes.find((r) => r.name === "origin")?.refs.push ?? "unknown";
      const remoteForLog = redactGitRemoteUrl(originUrl);
      this.logger.info({ branch: branchName, remote: remoteForLog }, "pushing");
      await git.push("origin", branchName, ["--set-upstream", "--force"]);
      this.logger.info({ branch: branchName, remote: remoteForLog }, "pushed");
    } catch (err) {
      const detail = err instanceof Error ? err.message : String(err);
      const remotes = await git.getRemotes(true).catch(() => []);
      const originUrl = remotes.find((r) => r.name === "origin")?.refs.push ?? "unknown";
      const remoteForLog = redactGitRemoteUrl(originUrl);
      this.logger.error(
        { branch: branchName, step: "push", remote: remoteForLog, error: detail },
        "commit_push_failed",
      );
      throw new RepoOperationError(`Failed to push: ${detail}`, { cause: err });
    }
  }

  async getDiffStats(): Promise<SafetyReport> {
    const git = this.getGit();
    const violations: string[] = [];

    try {
      const diffSummary = await git.diffSummary();
      const diff = await git.diff(["--name-only"]);
      const untrackedFiles = (await git.status()).not_added;
      const changedFiles = [...diff.split("\n").filter(Boolean), ...untrackedFiles].filter(
        (f) => !isLockfile(f) && !isBotManagedPath(f),
      );

      const filesDeleted = diffSummary.files.filter(
        (f) => f.binary === false && f.insertions === 0,
      ).length;
      const totalLinesChanged = diffSummary.insertions + diffSummary.deletions;

      if (changedFiles.length > SAFETY.MAX_FILES_CHANGED) {
        violations.push(
          `Too many files changed: ${changedFiles.length} > ${SAFETY.MAX_FILES_CHANGED}`,
        );
      }
      if (totalLinesChanged > SAFETY.MAX_DIFF_LINES) {
        violations.push(`Too many lines changed: ${totalLinesChanged} > ${SAFETY.MAX_DIFF_LINES}`);
      }
      if (filesDeleted > SAFETY.MAX_FILES_DELETED) {
        violations.push(`Too many files deleted: ${filesDeleted} > ${SAFETY.MAX_FILES_DELETED}`);
      }
      for (const file of changedFiles) {
        if (SAFETY.FORBIDDEN_PATHS.some((p) => file.startsWith(p) || file === p)) {
          violations.push(`Forbidden path modified: ${file}`);
        }
      }

      return {
        passed: violations.length === 0,
        totalLinesChanged,
        filesChanged: changedFiles.length,
        filesDeleted,
        violations,
      };
    } catch {
      return {
        passed: true,
        totalLinesChanged: 0,
        filesChanged: 0,
        filesDeleted: 0,
        violations: [],
      };
    }
  }

  /**
   * Stable hash of the working tree's "meaningful" state — modified and
   * untracked files, with contents — excluding lockfiles and bot-managed paths.
   *
   * Used by the fix pipeline to detect whether the agent actually
   * touched a repo: take a snapshot before runAgent, take another after, and
   * a repo is "changed" only if the fingerprints differ. This ignores side
   * effects from `installDependencies` (which run during `syncAllRepos` and
   * can dirty the tree even in repos the agent never visits).
   */
  async workingTreeFingerprint(): Promise<string> {
    const git = this.getGit();
    try {
      const status = await git.status();
      const meaningful = status.files
        .filter((f) => !isLockfile(f.path) && !isBotManagedPath(f.path))
        .sort((a, b) => a.path.localeCompare(b.path));

      if (meaningful.length === 0) return "clean";

      const hash = createHash("sha256");
      for (const f of meaningful) {
        hash.update(`${f.index}${f.working_dir} ${f.path}\n`);
        try {
          const fullPath = join(this.localPath, f.path);
          const stat = statSync(fullPath);
          if (stat.isFile() && stat.size <= 5 * 1024 * 1024) {
            hash.update(readFileSync(fullPath));
          } else {
            hash.update(`SIZE:${stat.size}`);
          }
        } catch {
          hash.update("MISSING");
        }
        hash.update("\n");
      }
      return hash.digest("hex");
    } catch {
      return "unknown";
    }
  }

  async hasChanges(baseBranch?: string): Promise<boolean> {
    const git = this.getGit();
    const status = await git.status();
    const meaningful = status.files.filter((f) => !isLockfile(f.path) && !isBotManagedPath(f.path));
    if (meaningful.length > 0) return true;

    // Also detect changes that the agent already committed to the branch.
    // Use getChangedFiles (which filters out rules-sync and lockfiles) instead
    // of a raw rev-list count so that commits containing only .cursor/rules/
    // symlinks are not treated as meaningful changes.
    if (baseBranch) {
      const changedFiles = await this.getChangedFiles(baseBranch);
      return changedFiles.length > 0;
    }

    return false;
  }

  async hasCommitsAhead(baseBranch: string): Promise<boolean> {
    const git = this.getGit();
    try {
      const ahead = await git.raw(["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
      return parseInt(ahead.trim(), 10) > 0;
    } catch {
      return false;
    }
  }

  async resetToBase(baseBranch: string): Promise<void> {
    const git = this.getGit();
    await git.reset(["--hard", `origin/${baseBranch}`]);
  }

  /**
   * Whether installDependencies would do meaningful work. Mirrors the gate in
   * validateBuild: no package.json → nothing to install; no node_modules → must
   * install; otherwise only when an install input (package.json / lockfile /
   * .yarnrc.yml / patches) changed. Lets syncAllRepos skip redundant installs.
   */
  async needsInstall(): Promise<boolean> {
    if (!existsSync(join(this.localPath, "package.json"))) return false;
    if (!existsSync(join(this.localPath, "node_modules"))) return true;
    return this.hasInstallInputsChanged();
  }

  async installDependencies(): Promise<void> {
    if (!existsSync(join(this.localPath, "package.json"))) return;

    const { cmd, installArgs } = detectPackageManager(this.localPath, this.installCmd);

    this.logger.info({ cmd: `${cmd} ${installArgs.join(" ")}` }, "installing_dependencies");
    const result = await this.runCommand(cmd, installArgs);

    if (!result.success) {
      this.logger.warn({ output: result.output.slice(-500) }, "install_failed");
    } else {
      this.logger.info("dependencies_installed");
    }
  }

  getLocalPath(): string {
    return this.localPath;
  }

  private getGit(): SimpleGit {
    if (!this.git) {
      throw new RepoOperationError("Repository not initialized — call ensureCloned() first");
    }
    return this.git;
  }

  private async hasInstallInputsChanged(): Promise<boolean> {
    try {
      const git = this.getGit();
      const status = await git.status();
      return (
        status.files.some((file) => isInstallInput(file.path)) ||
        status.not_added.some(isInstallInput)
      );
    } catch {
      return true;
    }
  }

  private hasScript(name: string): boolean {
    try {
      const pkg = JSON.parse(readFileSync(join(this.localPath, "package.json"), "utf-8"));
      return Boolean(pkg.scripts?.[name]);
    } catch {
      return false;
    }
  }

  private async runCommand(
    cmd: string,
    args: string[],
  ): Promise<{ success: boolean; output: string; exitCode: number }> {
    try {
      const baseEnv = { ...process.env, npm_config_engine_strict: "false" };
      const env = this.nodeBinPath
        ? { ...baseEnv, PATH: `${this.nodeBinPath}:${process.env.PATH}` }
        : baseEnv;

      const proc = Bun.spawn([cmd, ...args], {
        cwd: this.localPath,
        stdout: "pipe",
        stderr: "pipe",
        env,
      });

      const [stdout, stderr] = await Promise.all([
        new Response(proc.stdout).text(),
        new Response(proc.stderr).text(),
      ]);

      const exitCode = await proc.exited;
      const output = `${stdout}\n${stderr}`.trim();

      if (exitCode !== 0) {
        this.logger.warn({ cmd: `${cmd} ${args.join(" ")}`, exitCode }, "build_failed");
      }

      return { success: exitCode === 0, output, exitCode };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return { success: false, output: message, exitCode: -1 };
    }
  }
}
