import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

import { Injectable, Logger } from '@nestjs/common';

import { runGit } from '../../../lib/git/exec/run-git.js';
import { listRefs } from '../../../lib/git/refs/list-refs.js';
import { headRef } from '../../../lib/git/tree/resolve-ref.js';
import { emptyIndex, type WalIndex } from '../../../lib/git/wal/wal.types.js';
import { SingleFlight } from '../../../lib/single-flight.js';
import { RepositoryStorageService } from '../repository-storage/repository-storage.service.js';
import { WalStoreService } from '../wal/wal-store.service.js';

const SEQ_MARKER = 'ghost-wal-seq';
const DEFAULT_BRANCH_PREFERENCE = ['refs/heads/main', 'refs/heads/master'];

/** Brings a cached bare repository up to the log. The cache is only ever behind it, so replay is forward-only: apply the packs past the cached sequence, then reconcile refs to the index snapshot. */
@Injectable()
export class RepositoryMaterializerService {
  private readonly logger = new Logger(RepositoryMaterializerService.name);
  private readonly inFlight = new SingleFlight<WalIndex>();

  constructor(
    private readonly store: WalStoreService,
    private readonly storage: RepositoryStorageService,
  ) {}

  /** The repository's cache directory, current with its log. Everything that hands a repository to git opens it this way. */
  async open(repository: { id: string; defaultBranch: string | null }) {
    const directory = await this.storage.getRepoPath(repository.id);
    await this.materialize(repository.id, directory, repository.defaultBranch);
    return directory;
  }

  async materialize(
    repoId: string,
    repoDirectory: string,
    defaultBranch: string | null,
  ): Promise<WalIndex> {
    return this.inFlight.run(repoId, () =>
      this.replay(repoId, repoDirectory, defaultBranch),
    );
  }

  /** Resolves once a replay in flight has finished, whatever its outcome, so its writes cannot land after the cache is removed. */
  async settle(repoId: string) {
    await this.inFlight.settle(repoId);
  }

  private async replay(
    repoId: string,
    repoDirectory: string,
    defaultBranch: string | null,
  ): Promise<WalIndex> {
    const stored = await this.store.readIndex(repoId);
    if (!stored) return emptyIndex();

    const { index } = stored;
    const cachedSeq = await this.readCachedSeq(repoDirectory);
    // The default branch changes without a push, so HEAD is checked even when the cache is current.
    if (cachedSeq >= index.seq) {
      await this.ensureHead(repoDirectory, index, defaultBranch);
      return index;
    }

    for (const [offset, layer] of index.layers.entries()) {
      const seq = index.compactedThroughSeq + offset + 1;
      if (seq <= cachedSeq) continue;

      // Pushes since 0034 store self-contained packs, but older layers may be thin, with deltas against objects from earlier layers, which is why replay must stay in sequence order.
      if (layer.size > 0) {
        await runGit({
          args: ['index-pack', '--fix-thin', '--stdin'],
          gitDir: repoDirectory,
          input: await this.store.openEntryPack(repoId, layer.ulid),
        });
      }
    }

    await this.reconcileRefs(repoDirectory, index);
    await this.ensureHead(repoDirectory, index, defaultBranch);
    await writeFile(
      path.join(repoDirectory, SEQ_MARKER),
      `${index.seq}\n`,
      'utf8',
    );
    this.logger.log(
      `Materialized ${repoId} from seq ${cachedSeq} to ${index.seq}`,
    );

    return index;
  }

  private async reconcileRefs(repoDirectory: string, index: WalIndex) {
    const existing = [...(await listRefs(repoDirectory)).keys()];
    const commands: string[] = [];

    for (const [ref, oid] of index.refs) {
      commands.push(`update ${ref} ${oid.toString('hex')}`);
    }
    for (const ref of existing) {
      if (!index.refs.has(ref)) commands.push(`delete ${ref}`);
    }

    if (commands.length > 0) {
      await runGit({
        args: ['update-ref', '--stdin'],
        gitDir: repoDirectory,
        input: Buffer.from(commands.join('\n') + '\n', 'utf8'),
      });
    }
  }

  /** HEAD follows the chosen default branch while it exists. Otherwise a HEAD naming a missing branch clones as empty, so it is pointed at a branch that actually exists. */
  private async ensureHead(
    repoDirectory: string,
    index: WalIndex,
    defaultBranch: string | null,
  ) {
    if (index.refs.size === 0) return;

    const current = await headRef(repoDirectory);

    const chosen = defaultBranch && `refs/heads/${defaultBranch}`;
    const target =
      [chosen, current, ...DEFAULT_BRANCH_PREFERENCE].find(
        (ref) => ref && index.refs.has(ref),
      ) ?? [...index.refs.keys()].find((ref) => ref.startsWith('refs/heads/'));
    if (!target || target === current) return;

    await runGit({
      args: ['symbolic-ref', 'HEAD', target],
      gitDir: repoDirectory,
    });
  }

  private async readCachedSeq(repoDirectory: string) {
    try {
      const raw = await readFile(path.join(repoDirectory, SEQ_MARKER), 'utf8');
      const seq = Number.parseInt(raw.trim(), 10);
      return Number.isInteger(seq) && seq >= 0 ? seq : 0;
    } catch {
      return 0;
    }
  }
}
