import type { Database } from '@ghost/db';
import {
  Inject,
  Injectable,
  Logger,
  type OnApplicationShutdown,
  type OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ssh2, {
  type Connection,
  type PublicKeyAuthContext,
  type ServerChannel,
  type Session,
} from 'ssh2';

// ssh2 is CommonJS: a named import type-checks and then fails to resolve at runtime under ESM.
const { Server } = ssh2;
type SshServer = InstanceType<typeof Server>;

import { DATABASE } from '../../../database/database.module.js';
import { DomainError } from '../../../domain/errors.js';
import { GitService } from '../../../git/git.service.js';
import { errorMessage } from '../../../lib/error-message.js';
import {
  type GitServiceName,
  toGitBinary,
} from '../../../lib/git/protocol/git-service.js';
import { spoolToFile } from '../../../lib/git/protocol/spool.js';
import { greeting, replyTo } from '../../../lib/git/ssh/easter-eggs.js';
import {
  parseGitCommand,
  parseLfsAuthenticateCommand,
} from '../../../lib/git/ssh/git-command.js';
import {
  fingerprintOf,
  parseStoredKey,
} from '../../../lib/git/ssh/public-key.js';
import { oauthAppBlocked } from '../../../lib/organizations/oauth-app-access.js';
import {
  type Actor,
  type Repository,
} from '../../../lib/repositories/access/repository-access.js';
import { RepositoryNotFoundError } from '../../../lib/repositories/repositories.errors.js';
import { SshKeysService } from '../../../resources/ssh-keys/ssh-keys.service.js';
import { LfsService } from '../lfs/lfs.service.js';
import { RepositoryMaterializerService } from '../materializer/repository-materializer.service.js';
import { PackProcessService } from '../pack-process/pack-process.service.js';
import { RefAdvertisementService } from '../ref-advertisement/ref-advertisement.service.js';
import { RepositoryAccessService } from '../repository-access/repository-access.service.js';

/** 10-31. Railway already answers on 2222, and a ghost may as well keep Halloween. */
const DEFAULT_PORT = 1031;
const MAX_AUTH_ATTEMPTS = 6;
const GIT_FATAL_EXIT = 128;
/** A restart can race the previous process's socket release. */
const LISTEN_RETRIES = 10;
const LISTEN_RETRY_MS = 1_000;
/** A stuck peer must not hold the process after HTTP has already closed. */
const CLOSE_TIMEOUT_MS = 5_000;

interface SessionActor {
  actor: Actor;
  /** The account the key belongs to, not the login the client typed - every connection logs in as `git`. */
  username: string;
  /** The OAuth app that added the key, whose organization restrictions the connection inherits. */
  oauthClientId: string | null;
  /** A key added before its OAuth app was recorded, so it may have come from any app. */
  legacy: boolean;
}

/** The SSH transport: authenticates, then runs one of two git binaries or hands out git-lfs credentials. Nothing here is a shell: commands match a regex and git gets an argv array. */
@Injectable()
export class SshServerService implements OnModuleInit, OnApplicationShutdown {
  private readonly logger = new Logger(SshServerService.name);
  private server?: SshServer;
  /** net.Server.close() leaves open connections alone, so shutdown ends them itself. */
  private readonly clients = new Set<Connection>();
  private retryTimer?: NodeJS.Timeout;

  constructor(
    private readonly config: ConfigService,
    private readonly keys: SshKeysService,
    private readonly access: RepositoryAccessService,
    private readonly git: GitService,
    private readonly packProcess: PackProcessService,
    private readonly refAdvertisement: RefAdvertisementService,
    private readonly materializer: RepositoryMaterializerService,
    private readonly lfs: LfsService,
    @Inject(DATABASE) private readonly db: Database,
  ) {}

  onModuleInit() {
    const hostKey = this.config.get<string>('GIT_SSH_HOST_KEY');
    if (!hostKey) {
      this.logger.log('SSH transport off: GIT_SSH_HOST_KEY is not set');
      return;
    }

    const port = Number(this.config.get('GIT_SSH_PORT', DEFAULT_PORT));
    this.server = new Server(
      {
        // One value in the environment, either PEM or the same PEM base64-encoded so it survives a single-line config field.
        hostKeys: [
          hostKey.includes('-----BEGIN')
            ? hostKey.replace(/\\n/g, '\n')
            : Buffer.from(hostKey, 'base64').toString('utf8'),
        ],
        ident: 'ghost',
        // A clone can idle while the server packs objects, so the liveness check has to be generous enough not to cut one off.
        keepaliveInterval: 15_000,
        keepaliveCountMax: 8,
      },
      (client) => this.handle(client),
    );

    this.server.on('listening', () =>
      this.logger.log(`SSH transport listening on ${port}`),
    );
    let retries = 0;
    this.server.on('error', (error: NodeJS.ErrnoException) => {
      if (error.code === 'EADDRINUSE' && retries++ < LISTEN_RETRIES) {
        this.logger.warn(
          `SSH port ${port} busy, retry ${retries}/${LISTEN_RETRIES}`,
        );
        this.retryTimer = setTimeout(
          () => this.server?.listen(port, '0.0.0.0'),
          LISTEN_RETRY_MS,
        );
        return;
      }
      this.logger.error(`SSH server error: ${error.message}`);
    });
    this.server.listen(port, '0.0.0.0');
  }

  async onApplicationShutdown() {
    clearTimeout(this.retryTimer);
    const server = this.server;
    this.server = undefined;
    if (!server?.listening) return;
    await new Promise<void>((resolve) => {
      setTimeout(resolve, CLOSE_TIMEOUT_MS).unref();
      server.close(() => resolve());
      for (const client of this.clients) client.end();
    });
  }

  private handle(client: Connection) {
    this.clients.add(client);
    client.on('close', () => this.clients.delete(client));
    const state: SessionActor = {
      actor: null,
      username: '',
      oauthClientId: null,
      legacy: false,
    };
    let attempts = 0;

    client.on('authentication', (ctx) => {
      if (ctx.method !== 'publickey') {
        // Password and keyboard-interactive are never offered: a key is the only identity this server understands.
        if (++attempts >= MAX_AUTH_ATTEMPTS) return client.end();
        return ctx.reject(['publickey']);
      }

      void this.authenticate(ctx, state).catch((error: Error) => {
        this.logger.warn(`SSH authentication failed: ${error.message}`);
        ctx.reject();
      });
    });

    client.on('session', (accept) => this.session(accept(), state, client));
    // No forwarding of any kind: this connection carries git, or it carries nothing.
    client.on('request', (_accept, reject) => reject?.());
    client.on('tcpip', (_accept, reject) => reject());
    client.on('openssh.streamlocal', (_accept, reject) => reject());
    client.on('error', (error) =>
      this.logger.debug(`SSH client error: ${error.message}`),
    );
  }

  private async authenticate(ctx: PublicKeyAuthContext, state: SessionActor) {
    const row = await this.keys.findByFingerprint(fingerprintOf(ctx.key.data));
    if (!row) return ctx.reject();

    // No signature means the client is only asking whether the key would be accepted; the signed round follows.
    if (!ctx.signature || !ctx.blob) return ctx.accept();

    if (
      !parseStoredKey(row.publicKey).verify(
        ctx.blob,
        ctx.signature,
        ctx.hashAlgo,
      )
    ) {
      return ctx.reject();
    }

    state.actor = { userId: row.userId };
    state.username = row.username ?? 'there';
    state.oauthClientId = row.oauthClientId;
    state.legacy = row.legacy;
    ctx.accept();
    this.keys
      .markUsed(row.id)
      .catch((error: unknown) =>
        this.logger.warn(`Could not stamp key use: ${error}`),
      );
  }

  private session(session: Session, state: SessionActor, client: Connection) {
    let protocol: string | undefined;
    // OpenSSH sends `GIT_PROTOCOL` only when the client's git asks for v2; every other variable is refused.
    session.on('env', (accept, reject, info) => {
      if (info.key !== 'GIT_PROTOCOL') return reject?.();
      protocol = info.val;
      accept?.();
    });

    session.on('exec', (accept, _reject, info) => {
      const channel = accept();
      // The connection outlives the channel on purpose: ending it while megabytes are still queued behind the flow-control window truncates the fetch.
      channel.on('close', () => client.end());
      void this.exec(channel, info.command, state, protocol).catch(
        (error: Error) =>
          this.logger.error(`SSH session failed: ${error.message}`),
      );
    });

    session.on('shell', (accept) => {
      const channel = accept();
      channel.on('close', () => client.end());
      channel.write(greeting(state.username));
      end(channel, 1);
    });

    // A pty is refused rather than answered: OpenSSH prints one line about it and carries on to the greeting.
    session.on('pty', (_accept, reject) => reject?.());
    session.on('sftp', (_accept, reject) => reject?.());
    session.on('subsystem', (_accept, reject) => reject?.());
    session.on('x11', (_accept, reject) => reject?.());
  }

  private async exec(
    channel: ServerChannel,
    command: string,
    { actor, username, oauthClientId, legacy }: SessionActor,
    protocol: string | undefined,
  ) {
    this.logger.debug(`SSH exec ${command}`);
    const parsed =
      parseLfsAuthenticateCommand(command) ?? parseGitCommand(command);
    if (!parsed) {
      channel.stderr.write(`${replyTo(command, username)}\n`);
      return end(channel, 1);
    }

    try {
      const repository = await this.access.authorize({
        username: parsed.username,
        repo: parsed.repo,
        requesterId: actor?.userId,
        operation: (
          'service' in parsed
            ? parsed.service === 'git-receive-pack'
            : parsed.operation === 'upload'
        )
          ? 'write'
          : 'read',
      });
      // A key an OAuth app added cannot reach repositories of an organization that has not approved the app, as with its token over HTTP. A legacy key's app is unknown, so restrictions refuse it outright.
      if (
        (oauthClientId || legacy) &&
        repository.organizationId &&
        (await oauthAppBlocked(this.db, {
          clientId: oauthClientId,
          organizationId: repository.organizationId,
        }))
      )
        throw new RepositoryNotFoundError();
      if (!('service' in parsed)) {
        const credentials = this.lfs.authenticate({
          repositoryId: repository.id,
          path: `${parsed.username}/${repository.slug}`,
          userId: actor?.userId ?? null,
          operation: parsed.operation,
        });
        channel.write(`${JSON.stringify(credentials)}\n`);
        return end(channel, 0);
      }
      const repoDirectory = await this.materializer.open(repository);

      if (parsed.service === 'git-receive-pack') {
        await this.push(channel, repository, repoDirectory, actor);
        return;
      }
      await this.fetch(channel, repoDirectory, parsed.service, protocol);
    } catch (error) {
      // SSH has no status line to carry this, so the reason goes where git prints remote errors.
      const known = error instanceof DomainError;
      if (!known) {
        this.logger.error(`SSH ${command} failed: ${errorMessage(error)}`);
      }
      const message = known
        ? error.message
        : 'the server could not complete that request';
      channel.stderr.write(`ghost: ${message}\n`);
      end(channel, GIT_FATAL_EXIT);
    }
  }

  /** A fetch is a straight duplex: the client's wants go to git, git's packfile comes back, and nothing is buffered on the way. */
  private fetch(
    channel: ServerChannel,
    repoDirectory: string,
    service: GitServiceName,
    protocol: string | undefined,
  ) {
    const binary = toGitBinary(service);

    return new Promise<void>((resolve) => {
      const child = this.packProcess.spawnInteractive({
        repoDirectory,
        service,
        protocol,
      });
      let running = true;

      channel.pipe(child.stdin);
      // `end: false`: a pipe that closes the channel itself gets there before the exit status does, and a client that never reads one calls the whole fetch a transport error.
      child.stdout.pipe(channel, { end: false });
      // Teed rather than piped: git's diagnosis belongs on the client's terminal, and a server log holding nothing but `ECONNRESET` cannot answer why a clone failed.
      child.stderr.on('data', (chunk: Buffer) => {
        const message = chunk.toString();
        // git names the directory it failed on, which is a local cache path the client has no business learning.
        channel.stderr.write(message.replaceAll(repoDirectory, '<repository>'));
        this.logger.warn(`${binary} stderr: ${message.trim()}`);
      });

      // A client that hangs up mid-clone must not leave upload-pack packing objects nobody will read.
      channel.on('close', () => {
        if (!running) return;
        this.logger.debug(`${binary} killed: the client closed the channel`);
        child.kill('SIGTERM');
      });

      child.on('error', (error) => {
        this.logger.error(`${binary} could not run: ${error.message}`);
        channel.stderr.write(`ghost: ${binary} could not run\n`);
      });

      child.on('close', (code) => {
        running = false;
        if (code) this.logger.warn(`${binary} exited ${code}`);
        end(channel, code ?? 0);
        resolve();
      });
    });
  }

  /** A push is spooled, committed to the log, and only then replayed into git - the same order the HTTP transport uses, so both share one commit point. The spool starts before the advertisement is written: the client may answer the moment it reads the refs, and an unattended channel drops whatever arrives first. */
  private async push(
    channel: ServerChannel,
    repository: Repository,
    repoDirectory: string,
    actor: Actor,
  ) {
    const spooling = spoolToFile(channel);
    this.refAdvertisement
      .advertiseRefs({ repoDirectory, service: 'git-receive-pack' })
      .pipe(channel, { end: false });

    const spooled = await spooling;
    try {
      const { body } = await this.git.receivePack({
        repository,
        body: spooled.body,
        pushedBy: actor?.userId ?? null,
      });

      await new Promise<void>((resolve, reject) => {
        body.on('error', reject);
        body.on('end', resolve);
        body.pipe(channel, { end: false });
      });
      end(channel, 0);
    } finally {
      await spooled.discard().catch(() => {});
    }
  }
}

function end(channel: ServerChannel, code: number) {
  channel.exit(code);
  channel.end();
}
