import { execFile } from "node:child_process";
import { isAbsolute, join, relative } from "node:path";
import { promisify } from "node:util";

import { MAX_FILE_SIZE } from "../constants/rulesync-paths.js";
import {
  createTempDirectory,
  directoryExists,
  getFileSize,
  isSymlink,
  listDirectoryFiles,
  readFileContent,
  removeTempDirectory,
} from "../utils/file.js";
import type { Logger } from "../utils/logger.js";
import { findControlCharacter } from "../utils/validation.js";

const execFileAsync = promisify(execFile);

/** Timeout for all git CLI operations (60 seconds). */
const GIT_TIMEOUT_MS = 60_000;

const ALLOWED_URL_SCHEMES =
  /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/\/).+$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9.-]+:[a-zA-Z0-9_.+/~-]+$/;

const INSECURE_URL_SCHEMES = /^(git:\/\/|http:\/\/)/;

export class GitClientError extends Error {
  constructor(message: string, cause?: unknown) {
    super(message, { cause });
    this.name = "GitClientError";
  }
}

export function validateGitUrl(url: string, options?: { logger?: Logger }): void {
  const ctrl = findControlCharacter(url);
  if (ctrl) {
    throw new GitClientError(
      `Git URL contains control character ${ctrl.hex} at position ${ctrl.position}`,
    );
  }
  if (!ALLOWED_URL_SCHEMES.test(url)) {
    throw new GitClientError(
      `Unsupported or unsafe git URL: "${url}". Use https, ssh, git, or file schemes.`,
    );
  }
  if (INSECURE_URL_SCHEMES.test(url)) {
    options?.logger?.warn(
      `URL "${url}" uses an unencrypted protocol. Consider using https:// or ssh:// instead.`,
    );
  }
}

/**
 * Validate a ref string before passing to git commands.
 * Rejects refs that start with "-" or contain control characters.
 */
export function validateRef(ref: string): void {
  if (ref.startsWith("-")) {
    throw new GitClientError(`Ref must not start with "-": "${ref}"`);
  }
  const ctrl = findControlCharacter(ref);
  if (ctrl) {
    throw new GitClientError(
      `Ref contains control character ${ctrl.hex} at position ${ctrl.position}`,
    );
  }
}

let gitChecked = false;

export async function checkGitAvailable(): Promise<void> {
  if (gitChecked) return;
  try {
    await execFileAsync("git", ["--version"], { timeout: GIT_TIMEOUT_MS });
    gitChecked = true;
  } catch {
    throw new GitClientError("git is not installed or not found in PATH");
  }
}

/** Reset the cached git availability check (for testing). */
export function resetGitCheck(): void {
  gitChecked = false;
}

export async function resolveDefaultRef(url: string): Promise<{ ref: string; sha: string }> {
  validateGitUrl(url);
  await checkGitAvailable();
  try {
    const { stdout } = await execFileAsync("git", ["ls-remote", "--symref", "--", url, "HEAD"], {
      timeout: GIT_TIMEOUT_MS,
    });
    const ref = stdout.match(/^ref: refs\/heads\/(.+)\tHEAD$/m)?.[1];
    const sha = stdout.match(/^([0-9a-f]{40})\tHEAD$/m)?.[1];
    if (!ref || !sha) throw new GitClientError(`Could not parse default branch from: ${url}`);
    validateRef(ref);
    return { ref, sha };
  } catch (error) {
    if (error instanceof GitClientError) throw error;
    throw new GitClientError(`Failed to resolve default ref for ${url}`, error);
  }
}

export async function resolveRefToSha(url: string, ref: string): Promise<string> {
  validateGitUrl(url);
  validateRef(ref);
  await checkGitAvailable();
  try {
    const { stdout } = await execFileAsync("git", ["ls-remote", "--", url, ref], {
      timeout: GIT_TIMEOUT_MS,
    });
    const sha = stdout.match(/^([0-9a-f]{40})\t/m)?.[1];
    if (!sha) throw new GitClientError(`Ref "${ref}" not found in ${url}`);
    return sha;
  } catch (error) {
    if (error instanceof GitClientError) throw error;
    throw new GitClientError(`Failed to resolve ref "${ref}" for ${url}`, error);
  }
}

/**
 * Clone a repo at the given ref and return all files under skillsPath.
 * The `ref` must be a branch or tag name (not a commit SHA) because
 * `git clone --branch` does not accept raw SHAs.
 */
export async function fetchSkillFiles(params: {
  url: string;
  ref: string;
  skillsPath: string;
  logger?: Logger;
}): Promise<Array<{ relativePath: string; content: string; size: number }>> {
  const { url, ref, skillsPath, logger } = params;
  validateGitUrl(url, { logger });
  validateRef(ref);
  if (skillsPath.split(/[/\\]/).includes("..") || isAbsolute(skillsPath)) {
    throw new GitClientError(
      `Invalid skillsPath "${skillsPath}": must be a relative path without ".."`,
    );
  }
  const ctrl = findControlCharacter(skillsPath);
  if (ctrl) {
    throw new GitClientError(
      `skillsPath contains control character ${ctrl.hex} at position ${ctrl.position}`,
    );
  }
  await checkGitAvailable();
  const tmpDir = await createTempDirectory("rulesync-git-");
  try {
    await execFileAsync(
      "git",
      [
        "clone",
        "--depth",
        "1",
        "--branch",
        ref,
        "--no-checkout",
        "--filter=blob:none",
        "--",
        url,
        tmpDir,
      ],
      { timeout: GIT_TIMEOUT_MS },
    );
    await execFileAsync("git", ["-C", tmpDir, "sparse-checkout", "set", "--", skillsPath], {
      timeout: GIT_TIMEOUT_MS,
    });
    await execFileAsync("git", ["-C", tmpDir, "checkout"], { timeout: GIT_TIMEOUT_MS });
    const skillsDir = join(tmpDir, skillsPath);
    if (!(await directoryExists(skillsDir))) return [];
    return await walkDirectory(skillsDir, skillsDir, 0, { totalFiles: 0, totalSize: 0 }, logger);
  } catch (error) {
    if (error instanceof GitClientError) throw error;
    throw new GitClientError(`Failed to fetch skill files from ${url}`, error);
  } finally {
    await removeTempDirectory(tmpDir);
  }
}

const MAX_WALK_DEPTH = 20;
const MAX_TOTAL_FILES = 10_000;
const MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100 MB

/** Mutable context for tracking totals across recursive walkDirectory calls. */
type WalkContext = { totalFiles: number; totalSize: number };

async function walkDirectory(
  dir: string,
  outputRoot: string,
  depth: number = 0,
  ctx: WalkContext = { totalFiles: 0, totalSize: 0 },
  logger?: Logger,
): Promise<Array<{ relativePath: string; content: string; size: number }>> {
  if (depth > MAX_WALK_DEPTH) {
    throw new GitClientError(
      `Directory tree exceeds max depth of ${MAX_WALK_DEPTH}: "${dir}". Aborting to prevent resource exhaustion.`,
    );
  }
  const results: Array<{ relativePath: string; content: string; size: number }> = [];
  for (const name of await listDirectoryFiles(dir)) {
    if (name === ".git") continue;
    const fullPath = join(dir, name);
    if (await isSymlink(fullPath)) {
      logger?.warn(`Skipping symlink "${fullPath}".`);
      continue;
    }
    if (await directoryExists(fullPath)) {
      results.push(...(await walkDirectory(fullPath, outputRoot, depth + 1, ctx, logger)));
    } else {
      const size = await getFileSize(fullPath);
      if (size > MAX_FILE_SIZE) {
        logger?.warn(
          `Skipping file "${fullPath}" (${(size / 1024 / 1024).toFixed(2)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit).`,
        );
        continue;
      }
      ctx.totalFiles++;
      ctx.totalSize += size;
      if (ctx.totalFiles >= MAX_TOTAL_FILES) {
        throw new GitClientError(
          `Repository exceeds max file count of ${MAX_TOTAL_FILES}. Aborting to prevent resource exhaustion.`,
        );
      }
      if (ctx.totalSize >= MAX_TOTAL_SIZE) {
        throw new GitClientError(
          `Repository exceeds max total size of ${MAX_TOTAL_SIZE / 1024 / 1024}MB. Aborting to prevent resource exhaustion.`,
        );
      }
      const content = await readFileContent(fullPath);
      results.push({ relativePath: relative(outputRoot, fullPath), content, size });
    }
  }
  return results;
}
