import { describe, expect, it } from 'bun:test';
import { planSteps, runInstall, type InstallEffects, type StepName } from './installer';
import { byId } from './catalogue';

// The whole install machine, tested without Docker, Postgres, PM2 or an Immich to talk to — which is
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
// when something goes wrong: resume, blocking, and not doing work twice.

const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
const email = byId('email')!; // config only

/** Records what was actually called, so a test can assert on absence as well as presence. */
function spyEffects(over: Partial<InstallEffects> = {}) {
  const calls: string[] = [];
  const effects: InstallEffects = {
    preflight: async () => {
      calls.push('preflight');
      return { ok: true };
    },
    provision: async () => {
      calls.push('provision');
      return { results: { url: 'http://127.0.0.1:18091' }, composeDir: '/root/dockers/x' };
    },
    connect: async () => {
      calls.push('connect');
      return { status: 'done' };
    },
    applySchema: async () => void calls.push('schema'),
    publishAssets: async () => void calls.push('assets'),
    startProcess: async () => void calls.push('process'),
    provisionMembers: async () => void calls.push('members'),
    ...over,
  };
  return { effects, calls };
}

describe('planSteps', () => {
  it('never provisions when pointing at an instance the user already runs', () => {
    // The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather
    // than remembered at each call site.
    expect(planSteps(photos, 'existing')).not.toContain('provision');
    expect(planSteps(photos, 'provisioned')).toContain('provision');
  });

  it('omits the members step for a service with no user concept', () => {
    // Otherwise a Transmission install reports a members step that did nothing, which reads as a
    // silent failure to anyone debugging why a member has no access.
    expect(planSteps(transmission, 'provisioned')).not.toContain('members');
    expect(planSteps(photos, 'provisioned')).toContain('members');
  });

  it('has nothing to connect for a config-only install', () => {
    expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'assets', 'process']);
  });

  it('always starts with preflight', () => {
    // Checking the host before anything is written is the whole reason a half-install is avoidable.
    for (const mode of ['existing', 'provisioned', 'config'] as const) {
      expect(planSteps(photos, mode)[0]).toBe('preflight');
    }
  });
});

describe('a clean run', () => {
  it('executes the plan in order and reports installed', async () => {
    const { effects, calls } = spyEffects();
    const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });

    expect(out.status).toBe('installed');
    expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']);
  });

  it('feeds one step’s results forward to the next', async () => {
    // `provision` discovers the URL that `connect` writes down two steps later. Without this the
    // installer would have to ask the user for something it already knows.
    let seen: Record<string, string> = {};
    const { effects } = spyEffects({
      connect: async (ctx) => {
        seen = { ...ctx.values };
        return { status: 'done' };
      },
    });

    await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects });

    expect(seen.url).toBe('http://127.0.0.1:18091');
    expect(seen.composeDir).toBe('/root/dockers/x');
    expect(seen.given).toBe('yes');
  });

  it('does not mutate the caller’s values', async () => {
    const values = { given: 'yes' };
    const { effects } = spyEffects();
    await runInstall({ entry: photos, mode: 'provisioned', values, effects });
    expect(values).toEqual({ given: 'yes' });
  });
});

describe('resuming', () => {
  it('skips what an earlier attempt already did', async () => {
    // The point of persisting completedSteps: a resume must not provision a second container.
    const { effects, calls } = spyEffects();
    const done: StepName[] = ['preflight', 'provision', 'connect'];

    const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects });

    expect(out.status).toBe('installed');
    expect(calls).toEqual(['schema', 'assets', 'process', 'members']);
    expect(calls).not.toContain('provision');
  });

  it('reports every completed step, including the ones it skipped', async () => {
    const { effects } = spyEffects();
    const out = await runInstall({
      entry: photos,
      mode: 'provisioned',
      values: {},
      completed: ['preflight'],
      effects,
    });
    expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']);
  });
});

describe('blocking on a human', () => {
  it('stops without failing, and does NOT mark the blocking step done', async () => {
    // Immich: the container is up and healthy, and only its own UI can mint an API key. Recording
    // `connect` as complete would mean a resume skipped the very step that is waiting.
    const { effects, calls } = spyEffects({
      connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }),
    });

    const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });

    expect(out.status).toBe('blocked');
    if (out.status !== 'blocked') throw new Error('unreachable');
    expect(out.at).toBe('connect');
    expect(out.completeAt).toBe('http://x/keys');
    expect(out.completed).toEqual(['preflight', 'provision']);
    // Everything after the block is untouched — no process started against a service we cannot reach.
    expect(calls).not.toContain('process');
  });

  it('re-runs the blocking step on resume, once the human has answered', async () => {
    const { effects, calls } = spyEffects();
    const out = await runInstall({
      entry: photos,
      mode: 'provisioned',
      values: { secret: 'now-provided' },
      completed: ['preflight', 'provision'],
      effects,
    });

    expect(out.status).toBe('installed');
    expect(calls).toContain('connect');
  });
});

describe('failing', () => {
  it('stops at the failing step and keeps what came before', async () => {
    const { effects, calls } = spyEffects({
      applySchema: async () => {
        throw new Error('relation already exists');
      },
    });

    const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });

    expect(out.status).toBe('failed');
    if (out.status !== 'failed') throw new Error('unreachable');
    expect(out.at).toBe('schema');
    expect(out.error).toBe('relation already exists');
    // Resumable: the three that worked are recorded, so a retry does not redo them.
    expect(out.completed).toEqual(['preflight', 'provision', 'connect']);
    expect(calls).not.toContain('process');
  });

  it('treats a failed preflight as a failure before anything is written', async () => {
    const { effects, calls } = spyEffects({
      preflight: async () => {
        calls.push('preflight');
        return { ok: false, reason: 'Docker is not installed.', remedy: 'Install it.' };
      },
    });

    const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });

    expect(out.status).toBe('failed');
    if (out.status !== 'failed') throw new Error('unreachable');
    expect(out.at).toBe('preflight');
    // The remedy travels with the reason: "Docker is not installed" without "install it" is a dead end.
    expect(out.error).toContain('Install it.');
    // Nothing provisioned, so there is nothing to unwind — the entire reason preflight goes first.
    expect(calls).toEqual(['preflight']);
  });

  it('turns a thrown effect into a recorded failure rather than an escape', async () => {
    // An effect that throws is a bug in that effect. If it escaped, the row would be stranded in
    // `installing` with nothing to resume from.
    const { effects } = spyEffects({
      startProcess: async () => {
        throw new Error('pm2 not found');
      },
    });
    const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
    expect(out.status).toBe('failed');
    if (out.status !== 'failed') throw new Error('unreachable');
    expect(out.at).toBe('process');
  });
});
