import type { CatalogueEntry, InstallMode } from './catalogue';
import type { Preflight } from './preflight';

// Installing one sidecar, as a sequence of named steps that can stop anywhere and be resumed.
//
// ── Why a step machine and not a function ──
//
// Install spans a container start, a health wait, an upstream API call and a process start. Any of them
// can fail, and one of them (a token only a human can mint) is EXPECTED to stop the run. A straight-line
// function has two bad options at that point: unwind everything, or leave the user with a half-installed
// service that neither works nor uninstalls. The second is the one people cannot get out of.
//
// So each step is named, its completion is persisted in `sidecar_installs.completed_steps`, and running
// install again resumes from where it stopped. Re-running a completed step is never necessary, but is
// also never harmful — every effect below is required to be idempotent, because the alternative is
// trusting that a crash never lands between "did the thing" and "recorded the thing".
//
// ── Why the effects are injected ──
//
// `planSteps` is pure and `runInstall` takes its side effects as an argument, so the whole machine —
// ordering, resume, blocking, failure — is testable without Docker, Postgres, PM2 or an Immich to talk
// to. The parts that genuinely touch the world stay thin enough to read.

export type StepName =
  /** Host is capable of this: Docker present for a provisioned install, display present for vnc. */
  | 'preflight'
  /** Render the compose template and bring the containers up. Provisioned installs only. */
  | 'provision'
  /** Write the owner's `service_connections` row — the URL and credential this install is reachable by. */
  | 'connect'
  /** Apply the sidecar's own schema. */
  | 'schema'
  /** Copy the sidecar's icon and assets to where the browser can fetch them. */
  | 'assets'
  /** Start the PM2 process. */
  | 'process'
  /** Give every existing member their own account, where the service supports it. */
  | 'members';

export type StepResult =
  | { status: 'done'; results?: Record<string, string> }
  /**
   * Everything up to here worked and the run cannot continue without a human.
   *
   * A real state, not a failure: for Immich, Jellyfin and Memos the container is up and healthy and we
   * are waiting for a token only their own UI can mint. Reporting this as an error would make a normal
   * install look broken and invite the user to tear down a container that is working perfectly.
   */
  | { status: 'blocked'; reason: string; completeAt?: string }
  | { status: 'failed'; error: string };

/** What a step is given. Deliberately small — a step that needs more probably belongs in the sidecar. */
export type StepContext = {
  entry: CatalogueEntry;
  mode: InstallMode;
  /** Answers from the install form, plus anything earlier steps returned via `OFFICER_RESULT_*`. */
  values: Record<string, string>;
  /** Progress for the log the UI streams into a terminal panel. */
  log: (line: string) => void;
};

/**
 * The world, as the installer touches it. Every one of these MUST be idempotent — see above.
 */
export type InstallEffects = {
  preflight(entry: CatalogueEntry, mode: InstallMode): Promise<Preflight>;
  /** Run the sidecar's setup.sh. Returns whatever it printed as `OFFICER_RESULT_<KEY>=value`. */
  provision(ctx: StepContext): Promise<{ results: Record<string, string>; composeDir: string }>;
  /** Write the owner's connection row. `blocked` when the service can only be connected by a human. */
  connect(ctx: StepContext): Promise<StepResult>;
  applySchema(ctx: StepContext): Promise<void>;
  publishAssets(ctx: StepContext): Promise<void>;
  startProcess(ctx: StepContext): Promise<void>;
  provisionMembers(ctx: StepContext): Promise<void>;
};

/**
 * Which steps this install needs, in order — a pure function of the entry and the chosen mode.
 *
 * Separated from running them so the UI can show what is about to happen, and so ordering is testable
 * on its own. The two rules that matter:
 *
 *   - `provision` exists only for `provisioned`. Pointing at an instance the user already runs must
 *     never start a container, and this is where that is guaranteed rather than remembered.
 *   - `members` is omitted entirely when the service has no user concept, so a Transmission install does
 *     not report a members step that did nothing.
 */
export function planSteps(entry: CatalogueEntry, mode: InstallMode): StepName[] {
  const steps: StepName[] = ['preflight'];
  if (mode === 'provisioned') steps.push('provision');
  // `config` installs have nothing to point at, so there is no connection row to write.
  if (mode !== 'config') steps.push('connect');
  // Assets before the process: the dock reads manifests as soon as the install is recorded, and an icon
  // that arrives a moment later shows as broken on the first render.
  steps.push('schema', 'assets', 'process');
  if (entry.members !== 'none') steps.push('members');
  return steps;
}

export type InstallOutcome =
  | { status: 'installed'; completed: StepName[] }
  | { status: 'blocked'; completed: StepName[]; at: StepName; reason: string; completeAt?: string }
  | { status: 'failed'; completed: StepName[]; at: StepName; error: string };

export type RunInstallParams = {
  entry: CatalogueEntry;
  mode: InstallMode;
  values: Record<string, string>;
  /** Steps already done by an earlier attempt. Passing them is what makes this a resume. */
  completed?: StepName[];
  effects: InstallEffects;
  log?: (line: string) => void;
};

/**
 * Run (or resume) an install.
 *
 * Returns rather than throws, because every outcome here is something the caller has to record: a
 * failure updates `last_error` and leaves the row resumable, and a block is a normal pause. Throwing
 * would make the caller's job "catch and guess which of those happened".
 */
export async function runInstall(params: RunInstallParams): Promise<InstallOutcome> {
  const { entry, mode, effects } = params;
  const log = params.log ?? (() => {});
  const plan = planSteps(entry, mode);
  const completed = [...(params.completed ?? [])];
  // Copied, not aliased: a resumed run must not mutate the caller's record of what an earlier attempt
  // achieved, or a failure halfway through would silently rewrite history.
  const values = { ...params.values };

  for (const step of plan) {
    if (completed.includes(step)) {
      log(`· ${step} — already done, skipping`);
      continue;
    }

    const ctx: StepContext = { entry, mode, values, log };
    log(`▸ ${step}`);

    try {
      const result = await runStep(step, ctx, effects);

      if (result.status === 'failed') {
        return { status: 'failed', completed, at: step, error: result.error };
      }
      if (result.status === 'blocked') {
        // Note that `completed` does NOT include this step: resuming re-runs it, which is the point —
        // the human has now supplied what it was waiting for.
        return { status: 'blocked', completed, at: step, reason: result.reason, completeAt: result.completeAt };
      }

      // Results feed forward: `provision` discovers the URL a `connect` two steps later writes down.
      if (result.results) Object.assign(values, result.results);
      completed.push(step);
    } catch (err) {
      // An effect that throws is a bug in that effect, not a different kind of failure. Recording it the
      // same way keeps the row resumable instead of stranding it in `installing` forever.
      return { status: 'failed', completed, at: step, error: err instanceof Error ? err.message : String(err) };
    }
  }

  return { status: 'installed', completed };
}

async function runStep(step: StepName, ctx: StepContext, effects: InstallEffects): Promise<StepResult> {
  switch (step) {
    case 'preflight': {
      const check = await effects.preflight(ctx.entry, ctx.mode);
      return check.ok ? { status: 'done' } : { status: 'failed', error: `${check.reason} ${check.remedy}` };
    }
    case 'provision': {
      const { results, composeDir } = await effects.provision(ctx);
      return { status: 'done', results: { ...results, composeDir } };
    }
    case 'connect':
      return effects.connect(ctx);
    case 'schema':
      await effects.applySchema(ctx);
      return { status: 'done' };
    case 'assets':
      await effects.publishAssets(ctx);
      return { status: 'done' };
    case 'process':
      await effects.startProcess(ctx);
      return { status: 'done' };
    case 'members':
      await effects.provisionMembers(ctx);
      return { status: 'done' };
  }
}
