/**
 * Per-project dev-container git plumbing.
 *
 * The dev container's /workspace is a scratch volume by default. Without
 * this module, anything the AI writes there is invisible to the user —
 * the Product/Hosting/Infrastructure tabs all read from Gitea + Coolify,
 * not from the dev container's filesystem.
 *
 * Two responsibilities:
 *
 *   1. ensureProjectRepoCloned()
 *      Clones the project's Gitea repo into /workspace/<slug>/ on first
 *      use. Configures the AI's git author identity and embeds Gitea
 *      credentials into the remote URL so subsequent pushes work without
 *      another credential exchange. Idempotent — if the repo already
 *      exists at the expected path, it's a no-op.
 *
 *   2. commitAndPushIfDirty()
 *      Run at the end of each AI assistant turn. If there are changes
 *      under /workspace/<slug>/, stages them, makes a commit with a
 *      one-line summary, and pushes to origin. Failures are logged but
 *      don't fail the chat turn — the user already saw their reply.
 *
 * Why we shell out vs. using a JS git lib: the dev container has a real
 * git binary, the credentials are simple (HTTPS+token), and isomorphic
 * git would mean shipping a ~2MB JS dep just to mirror what `git` already
 * does. Cost outweighs benefit.
 *
 * Security note: the Gitea token is embedded in the remote URL, which
 * lives in `.git/config` inside the dev container's volume. The volume
 * is per-project and only reachable through our own SSH-bridge exec, so
 * the blast radius is the same as `shell.exec` itself — anything the AI
 * can do can read this token. That's already true today; this doesn't
 * widen it.
 */

import { execInDevContainer } from "@/lib/dev-container";

const GITEA_API_URL = process.env.GITEA_API_URL ?? "";
// Falls back to GITEA_ADMIN_USER because production historically only set the
// admin var; missing GITEA_USERNAME used to silently disable auto-clone.
const GITEA_USERNAME =
  process.env.GITEA_USERNAME || process.env.GITEA_ADMIN_USER || "";
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? "";

const AI_GIT_AUTHOR_NAME = "Vibn AI";
const AI_GIT_AUTHOR_EMAIL = "ai@vibnai.com";

/** Where each project's repo lives inside the dev container. */
export function projectRepoPath(projectSlug: string): string {
  return `/workspace`;
}

function isGiteaConfigured(): boolean {
  return Boolean(GITEA_API_URL && GITEA_USERNAME && GITEA_API_TOKEN);
}

/**
 * Take a Gitea HTTPS URL and embed the AI's credentials inline so
 * `git clone` / `git push` work without an interactive prompt.
 *
 * Input:  https://git.vibnai.com/vibn-mark/manifest.git
 * Output: https://mark:<token>@git.vibnai.com/vibn-mark/manifest.git
 */
function authedCloneUrl(plainCloneUrl: string): string {
  if (!isGiteaConfigured()) return plainCloneUrl;
  try {
    const u = new URL(plainCloneUrl);
    u.username = GITEA_USERNAME;
    u.password = GITEA_API_TOKEN;
    return u.toString();
  } catch {
    return plainCloneUrl;
  }
}

export interface EnsureRepoClonedOpts {
  projectId: string;
  projectSlug: string;
  giteaCloneUrl: string;
}

export interface EnsureRepoClonedResult {
  cloned: boolean;
  alreadyPresent: boolean;
  reason?: string;
}

/**
 * Make sure the project's Gitea repo is checked out at /workspace/<slug>/.
 *
 * Skips silently if the repo is already present (detected by
 * `git rev-parse --is-inside-work-tree`). Logs and returns a reason
 * string if Gitea isn't configured or the clone fails — callers should
 * treat clone failure as non-fatal: the dev container is still usable,
 * the AI just won't be able to surface its work in the UI until the
 * repo is fixed.
 */
export async function ensureProjectRepoCloned(
  opts: EnsureRepoClonedOpts,
): Promise<EnsureRepoClonedResult> {
  if (!isGiteaConfigured()) {
    return {
      cloned: false,
      alreadyPresent: false,
      reason: "gitea_not_configured",
    };
  }
  if (!opts.giteaCloneUrl) {
    return { cloned: false, alreadyPresent: false, reason: "no_clone_url" };
  }

  const repoDir = projectRepoPath(opts.projectSlug);
  const authed = authedCloneUrl(opts.giteaCloneUrl);

  // Tri-state probe:
  //   "git"     → /workspace/<slug>/.git exists (proper repo, no-op)
  //   "dir"     → /workspace/<slug>/ exists but isn't a repo (init in place)
  //   "absent"  → nothing there yet (full clone)
  //
  // The "dir" case happens when an earlier AI turn (pre-this-fix)
  // created files under /workspace/<slug>/ before we had auto-clone.
  // We init-git in place so those files become the first commit
  // instead of being clobbered by a clone-and-rename.
  const probe = await execInDevContainer({
    projectId: opts.projectId,
    command:
      `if [ -d ${shellQ(repoDir + "/.git")} ]; then echo git; ` +
      `elif [ -d ${shellQ(repoDir)} ]; then echo dir; ` +
      `else echo absent; fi`,
    timeoutMs: 5_000,
  });
  const probeState = probe.stdout.trim();

  if (probeState === "git") {
    return { cloned: false, alreadyPresent: true };
  }

  if (probeState === "dir") {
    // Init in place, hook up the remote, fetch, set tracking. We
    // don't try to merge — the directory's contents become whatever
    // the next auto-commit-and-push picks up. If the remote has
    // existing commits that conflict, the push will fail loudly
    // and the AI can resolve via shell.exec.
    const initCmd = [
      `cd ${shellQ(repoDir)}`,
      `git init -b main`,
      `git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
      `git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
      `git remote add origin ${shellQ(authed)}`,
      // Best-effort fetch; if remote is empty this errors out and
      // we proceed anyway. The `|| true` keeps the chain going.
      `(git fetch origin main 2>/dev/null && git reset --soft origin/main 2>/dev/null) || true`,
    ].join(" && ");

    const result = await execInDevContainer({
      projectId: opts.projectId,
      command: initCmd,
      timeoutMs: 30_000,
    });
    if (result.exitCode !== 0) {
      return {
        cloned: false,
        alreadyPresent: false,
        reason: `init_in_place_failed:${result.stderr.slice(0, 200)}`,
      };
    }
    return { cloned: true, alreadyPresent: false };
  }

  // probeState === 'absent' → full clone path.
  // Clone into a temp path then atomic-rename, so a clone interrupted
  // halfway through doesn't leave a half-checked-out tree at the real
  // path that the next attempt would mistake for "already present".
  const tmpDir = `${repoDir}.cloning.${Date.now()}`;
  const cloneCmd = [
    `mkdir -p /workspace`,
    // --depth 50 keeps the dev container small; we don't need full
    // history, and `--no-single-branch` would let the AI fetch other
    // branches if needed. For empty repos this errors with "remote
    // HEAD refers to nonexistent ref" — handle that with the dir
    // fallback below.
    `git clone --depth 50 ${shellQ(authed)} ${shellQ(tmpDir)}`,
    `cd ${shellQ(tmpDir)}`,
    `git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
    `git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
    `git remote set-url origin ${shellQ(authed)}`,
    `cd /workspace && mv ${shellQ(tmpDir)} ${shellQ(repoDir)}`,
  ].join(" && ");

  const result = await execInDevContainer({
    projectId: opts.projectId,
    command: cloneCmd,
    timeoutMs: 60_000,
  });

  if (result.exitCode !== 0) {
    await execInDevContainer({
      projectId: opts.projectId,
      command: `rm -rf ${shellQ(tmpDir)}`,
      timeoutMs: 5_000,
    }).catch(() => {});

    // Empty-repo fallback: if Gitea returned the repo is empty
    // (warning: "remote HEAD refers to nonexistent ref"), clone
    // didn't actually fail — just there's no history to check
    // out. Make an empty dir + init so subsequent commits land.
    if (
      /empty|warning: You appear to have cloned an empty/i.test(
        result.stderr + result.stdout,
      )
    ) {
      const initEmpty = [
        `mkdir -p ${shellQ(repoDir)}`,
        `cd ${shellQ(repoDir)}`,
        `git init -b main`,
        `git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
        `git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
        `git remote add origin ${shellQ(authed)}`,
      ].join(" && ");
      const initR = await execInDevContainer({
        projectId: opts.projectId,
        command: initEmpty,
        timeoutMs: 15_000,
      }).catch(() => null);
      if (initR && initR.exitCode === 0) {
        return { cloned: true, alreadyPresent: false };
      }
    }

    return {
      cloned: false,
      alreadyPresent: false,
      reason: `clone_failed:${result.stderr.slice(0, 200)}`,
    };
  }

  return { cloned: true, alreadyPresent: false };
}

export interface CommitAndPushOpts {
  projectId: string;
  projectSlug: string;
  /** One-line summary; defaults to a generic checkpoint message. */
  message?: string;
}

export interface CommitAndPushResult {
  committed: boolean;
  pushed: boolean;
  /** Short reason ('clean', 'no_repo', 'commit_failed:<err>', etc.). */
  reason?: string;
  /** Commit hash if a commit was made. */
  sha?: string;
}

/**
 * Stage every change under /workspace/<slug>/, commit if there's a diff,
 * and push to origin.
 *
 * Designed to be called at the end of each assistant turn. Cheap when
 * there are no changes (one `git status --porcelain` and we're out).
 * Never throws — the chat reply has already been streamed to the user
 * by the time this runs, and a failed push shouldn't surface as a
 * user-visible error in the conversation.
 */
export async function commitAndPushIfDirty(
  opts: CommitAndPushOpts,
): Promise<CommitAndPushResult> {
  const repoDir = projectRepoPath(opts.projectSlug);
  const message = (opts.message ?? "").trim() || "AI checkpoint";
  // Sanitize: keep messages single-line and bounded so we can't be
  // tricked into shell-escape or commit-message injection.
  const safeMessage = message.replace(/[\r\n]+/g, " ").slice(0, 200);

  // Bail fast if there's no repo to commit against. Don't treat as
  // an error — projects without a clone (e.g. cloning failed earlier)
  // should still be able to chat.
  const probe = await execInDevContainer({
    projectId: opts.projectId,
    command: `test -d ${shellQ(repoDir + "/.git")} && echo present || echo absent`,
    timeoutMs: 5_000,
  }).catch(() => null);
  if (!probe || probe.stdout.trim() !== "present") {
    return { committed: false, pushed: false, reason: "no_repo" };
  }

  const cmd = [
    `cd ${shellQ(repoDir)}`,
    `git add -A`,
    // If there's nothing staged, `diff --cached --quiet` exits 0 and
    // we short-circuit with a clean status. Otherwise commit + push.
    `if git diff --cached --quiet; then echo CLEAN && exit 0; fi`,
    `git commit -m ${shellQ(safeMessage)}`,
    `SHA=$(git rev-parse --short HEAD)`,
    `git push -u origin HEAD 2>&1 | tail -n 5`,
    `echo COMMITTED $SHA`,
  ].join(" && ");

  const result = await execInDevContainer({
    projectId: opts.projectId,
    command: cmd,
    timeoutMs: 30_000,
  }).catch(
    (err: unknown) =>
      ({
        stdout: "",
        stderr: err instanceof Error ? err.message : String(err),
        exitCode: -1,
      }) satisfies { stdout: string; stderr: string; exitCode: number },
  );

  const out = result.stdout || "";
  if (out.includes("CLEAN")) {
    return { committed: false, pushed: false, reason: "clean" };
  }
  const m = /COMMITTED\s+([0-9a-f]+)/.exec(out);
  if (m) {
    return { committed: true, pushed: true, sha: m[1] };
  }
  // We staged something but didn't see the COMMITTED marker — likely
  // commit succeeded but push failed (offline, auth rotated, etc.).
  // Surface the stderr tail so debugging is possible.
  return {
    committed: false,
    pushed: false,
    reason: `commit_or_push_failed:${(result.stderr || out).slice(-200)}`,
  };
}

function shellQ(s: string): string {
  return `'${s.replace(/'/g, `'\\''`)}'`;
}
