/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */
import { execFile } from "node:child_process";
import { promisify } from "node:util";

import { Context, Effect, Layer, Schema } from "effect";

import { GitBranchName } from "./git.js";

const execFileP = promisify(execFile);

// ---------------------------------------------------------------------------
// Shell abstraction: local git runs as subprocesses, but the same contract can
// be backed by an AgentOS VM exec so worktree ops work identically inside a VM.
// ---------------------------------------------------------------------------

export interface ShellExecOptions {
  readonly cwd?: string;
  readonly env?: Record<string, string>;
  readonly timeoutMs?: number;
}

export interface ShellExecResult {
  readonly exitCode: number;
  readonly stderr: string;
  readonly stdout: string;
}

export interface Shell {
  readonly exec: (
    command: string,
    args: readonly string[],
    options?: ShellExecOptions
  ) => Effect.Effect<ShellExecResult, GitLocalError>;
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

export const GitLocalErrorReason = Schema.Literals([
  "InvalidInput",
  "CommandFailed",
  "Timeout",
  "NotARepository",
]);
export type GitLocalErrorReason = typeof GitLocalErrorReason.Type;

export class GitLocalError extends Schema.TaggedErrorClass<GitLocalError>()(
  "GitLocalError",
  {
    message: Schema.String,
    reason: GitLocalErrorReason,
  }
) {}

// ---------------------------------------------------------------------------
// Inputs / outputs
// ---------------------------------------------------------------------------

export interface CloneInput {
  readonly into: string;
  readonly url: string;
}

export interface AddWorktreeInput {
  readonly branch: string;
  readonly path: string;
  readonly repositoryPath: string;
}

export interface CommitInput {
  readonly message: string;
  readonly repositoryPath: string;
}

export interface PushInput {
  readonly refspec: string;
  readonly repositoryPath: string;
}

export interface RemoteWithAuthInput {
  readonly host: string;
  readonly name?: string;
  readonly owner: string;
  readonly path: string;
  readonly repositoryPath: string;
  readonly token: string;
  readonly user: string;
}

// ---------------------------------------------------------------------------
// Operations: each is a pure function of (Shell, input) -> Effect.
// ---------------------------------------------------------------------------

const git = (
  shell: Shell,
  args: readonly string[],
  options?: ShellExecOptions
): Effect.Effect<string, GitLocalError> =>
  shell.exec("git", args, options).pipe(
    Effect.flatMap((res) =>
      res.exitCode === 0
        ? Effect.succeed(res.stdout.trim())
        : Effect.fail(
            new GitLocalError({
              message: res.stderr.trim() || `git ${args.join(" ")} failed`,
              reason: /not a git repository|does not appear to be/iu.test(
                res.stderr
              )
                ? "NotARepository"
                : "CommandFailed",
            })
          )
    )
  );

export const clone = (
  shell: Shell,
  input: CloneInput
): Effect.Effect<string, GitLocalError> =>
  git(shell, ["clone", input.url, input.into]).pipe(
    Effect.map(() => input.into)
  );

export const addWorktree = (
  shell: Shell,
  input: AddWorktreeInput
): Effect.Effect<string, GitLocalError> =>
  Effect.gen(function* run() {
    yield* Schema.decodeUnknownEffect(GitBranchName)(input.branch).pipe(
      Effect.mapError(
        (cause) =>
          new GitLocalError({ message: cause.message, reason: "InvalidInput" })
      )
    );
    // -b creates the branch if it does not exist; -B would reset. Use -b.
    yield* git(shell, ["worktree", "add", "-b", input.branch, input.path], {
      cwd: input.repositoryPath,
    });
    return input.path;
  });

export const commit = (
  shell: Shell,
  input: CommitInput
): Effect.Effect<string, GitLocalError> =>
  Effect.gen(function* run() {
    yield* git(shell, ["add", "-A"], { cwd: input.repositoryPath });
    yield* git(shell, ["commit", "-m", input.message], {
      cwd: input.repositoryPath,
    });
    return yield* git(shell, ["rev-parse", "HEAD"], {
      cwd: input.repositoryPath,
    });
  });

export const push = (
  shell: Shell,
  input: PushInput
): Effect.Effect<string, GitLocalError> =>
  git(shell, ["push", "-u", "origin", input.refspec], {
    cwd: input.repositoryPath,
  });

export const currentBranch = (
  shell: Shell,
  repositoryPath: string
): Effect.Effect<string, GitLocalError> =>
  git(shell, ["rev-parse", "--abbrev-ref", "HEAD"], {
    cwd: repositoryPath,
  });

export const setRemoteUrl = (
  shell: Shell,
  input: RemoteWithAuthInput
): Effect.Effect<string, GitLocalError> => {
  // Token-in-URL auth for push from a sandboxed checkout. The remote URL is
  // scoped to this single repo and the credential never leaves the worktree.
  const url = `https://${input.user}:${input.token}@${input.host}/${input.owner}/${input.path}`;
  const name = input.name ?? "origin";
  return git(shell, ["remote", "set-url", name, url], {
    cwd: input.repositoryPath,
  }).pipe(Effect.map(() => url));
};

// ---------------------------------------------------------------------------
// Default host shell (subprocess). The same Shell contract is implemented by
// the AgentOS VM adapter so worktree ops run identically inside a VM.
// ---------------------------------------------------------------------------

// execFile resolves only on success; non-zero exits throw with stderr in the
// message. We normalize that into a ShellExecResult so callers map exit codes.
const runExecFile = async (
  command: string,
  args: readonly string[],
  options?: ShellExecOptions
): Promise<ShellExecResult> => {
  try {
    const { stdout, stderr } = await execFileP(command, [...args], {
      cwd: options?.cwd,
      env: options?.env,
      maxBuffer: 1024 * 1024 * 8,
      timeout: options?.timeoutMs,
    });
    return { exitCode: 0, stderr, stdout };
  } catch (error) {
    const failed = error as NodeJS.ErrnoException & {
      stdout?: string;
      stderr?: string;
    };
    return {
      exitCode: 1,
      stderr: `${failed.stderr ?? ""}${failed.message}`,
      stdout: failed.stdout ?? "",
    };
  }
};

export const nodeShell: Shell = {
  exec: (command, args, options) =>
    Effect.tryPromise({
      catch: (cause) =>
        new GitLocalError({
          message: cause instanceof Error ? cause.message : "Spawn failed",
          reason: "CommandFailed",
        }),
      try: () => runExecFile(command, args, options),
    }),
};

// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------

interface GitLocalRuntimeShape {
  readonly shell: Shell;
  readonly clone: (input: CloneInput) => Effect.Effect<string, GitLocalError>;
  readonly addWorktree: (
    input: AddWorktreeInput
  ) => Effect.Effect<string, GitLocalError>;
  readonly commit: (input: CommitInput) => Effect.Effect<string, GitLocalError>;
  readonly push: (input: PushInput) => Effect.Effect<string, GitLocalError>;
  readonly currentBranch: (
    repositoryPath: string
  ) => Effect.Effect<string, GitLocalError>;
  readonly setRemoteUrl: (
    input: RemoteWithAuthInput
  ) => Effect.Effect<string, GitLocalError>;
}

export class GitLocalRuntime extends Context.Service<
  GitLocalRuntime,
  GitLocalRuntimeShape
>()("@code/primitives/git-local-runtime/GitLocalRuntime") {}

export const makeGitLocalRuntimeLayer = (shell: Shell = nodeShell) =>
  Layer.succeed(
    GitLocalRuntime,
    GitLocalRuntime.of({
      addWorktree: (input) => addWorktree(shell, input),
      clone: (input) => clone(shell, input),
      commit: (input) => commit(shell, input),
      currentBranch: (repositoryPath) => currentBranch(shell, repositoryPath),
      push: (input) => push(shell, input),
      setRemoteUrl: (input) => setRemoteUrl(shell, input),
      shell,
    })
  );
