import { runGit } from '../exec/run-git.js';

const RECORD = '\x1e';
const FIELD = '\x1f';
const SUMMARY_FORMAT = `--format=%H${FIELD}%ct${FIELD}%s`;
const FORMAT = `--format=${RECORD}%H${FIELD}%an${FIELD}%ae${FIELD}%ct${FIELD}%at${FIELD}%cn${FIELD}%ce${FIELD}%s${FIELD}%b`;

export interface Commit {
  sha: string;
  authorName: string;
  authorEmail: string;
  /** Committer timestamp, ISO 8601. */
  committedAt: string;
  /** Author timestamp, ISO 8601; differs from `committedAt` once a commit is rebased or amended. */
  authoredAt: string;
  committerName: string;
  committerEmail: string;
  subject: string;
  body: string;
}

export interface CommitSummary {
  sha: string;
  subject: string;
  /** Committer timestamp, ISO 8601. */
  committedAt: string;
}

export interface CommitFile {
  /** A for added, M for modified, D for deleted. */
  status: string;
  path: string;
}

/** A commit-ish a caller supplied, safe to hand git as a revision. */
export function isSha(value: string) {
  return /^[0-9a-f]{4,40}$/.test(value);
}

function parse(record: string): Commit | null {
  const [
    sha,
    authorName,
    authorEmail,
    committedAt,
    authoredAt,
    committerName,
    committerEmail,
    subject,
    body,
  ] = record.split(FIELD);
  if (!sha) return null;

  return {
    sha,
    authorName,
    authorEmail,
    committedAt: new Date(Number(committedAt) * 1000).toISOString(),
    authoredAt: new Date(Number(authoredAt) * 1000).toISOString(),
    committerName,
    committerEmail,
    subject,
    body: (body ?? '').trim(),
  };
}

/** Newest-first history of a ref, one page at a time. `cursor` is the sha to resume from, which stays correct as new commits land on top. */
export async function listCommits({
  gitDir,
  env,
  ref,
  path,
  limit,
  cursor,
  skip,
}: {
  gitDir: string;
  env?: Record<string, string>;
  /** A revision, or several such as `[tip, '^excluded']`. */
  ref: string | string[];
  path?: string;
  limit: number;
  cursor?: string;
  /** Commits to pass over first, newest first; how a caller reaches the oldest ones, since `--reverse` only reorders after `-n` has cut. */
  skip?: number;
}): Promise<{ commits: Commit[]; nextCursor: string | null }> {
  const args = [
    'log',
    FORMAT,
    // one extra row answers "is there another page" without a second walk
    '-n',
    String(limit + 1),
    ...(skip ? [`--skip=${skip}`] : []),
    '--end-of-options',
    ...(cursor ? [cursor] : [ref].flat()),
  ];
  if (path) args.push('--', path);

  const raw = await runGit({ args, gitDir, env });
  const commits = raw.split(RECORD).flatMap((record) => parse(record) ?? []);

  return {
    commits: commits.slice(0, limit),
    nextCursor: commits.length > limit ? commits[limit].sha : null,
  };
}

/** One commit with the paths it changed, or null when the sha is unknown. */
export async function readCommit({
  gitDir,
  sha,
}: {
  gitDir: string;
  sha: string;
}): Promise<(Commit & { files: CommitFile[] }) | null> {
  const raw = await runGit({
    args: ['show', '-s', FORMAT, '--end-of-options', sha],
    gitDir,
  }).catch(() => '');

  const commit = parse(raw.split(RECORD)[1] ?? '');
  if (!commit) return null;

  const changes = await runGit({
    args: [
      'show',
      '--name-status',
      '--format=',
      '-z',
      '--no-renames',
      // a merge shows nothing without this, and its first-parent diff is what a reader expects to see
      '--first-parent',
      '--end-of-options',
      commit.sha,
    ],
    gitDir,
  });

  const parts = changes.split('\0').filter(Boolean);
  const files: CommitFile[] = [];
  for (let i = 0; i + 1 < parts.length; i += 2) {
    files.push({ status: parts[i], path: parts[i + 1] });
  }

  return { ...commit, files };
}

/** The newest commit touching `path`, or the tip of `ref` when no path is given. Null when the ref has no history. */
export async function readCommitSummary({
  gitDir,
  ref,
  path,
}: {
  gitDir: string;
  ref: string;
  path?: string;
}): Promise<CommitSummary | null> {
  const args = ['log', '-1', SUMMARY_FORMAT, '--end-of-options', ref];
  if (path) args.push('--', path);

  const raw = await runGit({ args, gitDir }).catch(() => '');
  const [sha, committedAt, subject] = raw.trim().split(FIELD);
  if (!sha) return null;

  return {
    sha,
    subject,
    committedAt: new Date(Number(committedAt) * 1000).toISOString(),
  };
}

export async function countCommits({
  gitDir,
  range,
  path,
  env,
}: {
  gitDir: string;
  range: string;
  path?: string;
  env?: Record<string, string>;
}) {
  const count = await runGit({
    args: [
      'rev-list',
      '--count',
      '--end-of-options',
      range,
      ...(path ? ['--', path] : []),
    ],
    gitDir,
    env,
  });
  return Number(count.trim()) || 0;
}
