import { join } from 'node:path';
import { getOwnerUser, saveServiceConnection, getServiceConnection } from 'officerdb';
import type { InstallEffects, StepContext } from './installer';
import { preflight } from './preflight';
import { runSetupScript } from './run-script';
import { startProcess } from './pm2';
import { serviceDir } from './paths';
import { publishAssets } from './assets';
import { checkServiceUrl } from './url';

// The installer's steps, wired to the actual world.
//
// Kept apart from `installer.ts` on purpose: that file is the machine — ordering, resume, blocking —
// and is tested with these replaced by spies. This file is the thin part that genuinely touches PM2,
// Postgres and the filesystem, and is deliberately boring enough to read in one sitting.

/** Where the platform lives, for `pm2 start ecosystem.config.cjs`. */
const PLATFORM_DIR = process.cwd();

const TEMPLATES_DIR = join(import.meta.dir, 'templates');

export function createEffects(): InstallEffects {
  return {
    preflight: (entry, mode) => preflight(entry, mode),

    async provision(ctx: StepContext) {
      const template = ctx.entry.composeTemplate;
      // planSteps only emits `provision` for a provisioned install, and the catalogue test asserts
      // every such entry has a template — so reaching here without one is a broken catalogue, not a
      // user error, and it should say so rather than fail obscurely inside the runner.
      if (!template) throw new Error(`${ctx.entry.id} has no compose template but was asked to provision`);

      const result = await runSetupScript({
        sidecarId: ctx.entry.id,
        templateDir: join(TEMPLATES_DIR, template),
        env: ctx.values,
        log: ctx.log,
      });
      if (!result.ok) throw new Error(result.error);

      return { results: result.results, composeDir: result.serviceDir };
    },

    async connect(ctx: StepContext) {
      // Validated and normalised before anything is stored. What the user typed may be a bare hostname,
      // may carry a trailing slash that doubles a separator later, or may hide a token in a query
      // string — and none of those are visible again once written.
      const raw = ctx.values.url;
      if (raw) {
        const checked = checkServiceUrl(raw);
        if (!checked.ok) {
          return { status: 'blocked', reason: `${ctx.entry.label}: ${checked.error}` };
        }
        ctx.values.url = checked.url;
      }
      const url = ctx.values.url;
      // A provisioned install knows its own URL because the setup script printed it. An `existing` one
      // was given it in the form. Neither having produced one means we cannot reach the service, and
      // the honest answer is to stop rather than write a row that points nowhere.
      if (!url) {
        return {
          status: 'blocked',
          reason: `${ctx.entry.label} is running, but no connection URL was provided.`,
        };
      }

      // Shape 3 from templates/README.md: Immich, Jellyfin and Memos mint their API key in their own
      // UI, so a provisioned install is up and healthy but not yet connectable. That is a pause, not a
      // failure — see markBlocked.
      const needsSecret = ctx.entry.existingFields?.some((f) => f.key === 'secret' && f.required);
      if (needsSecret && !ctx.values.secret) {
        return {
          status: 'blocked',
          reason: `${ctx.entry.label} needs an API key, which only its own interface can create.`,
          completeAt: url,
        };
      }

      const owner = await getOwnerUser();
      if (!owner) throw new Error('no owner account');

      // Never overwrite a connection the owner already has, unless they said so.
      //
      // Found the hard way: installing a sidecar that was already configured replaced its connection
      // row with the new install's, silently repointing a working service at something else. On a fresh
      // machine that is harmless; on one with an existing setup it breaks a feature that was fine, and
      // the only symptom is that it stops working.
      //
      // Blocking rather than failing, because there is a sensible answer and the user is the only one
      // who has it: keep what is there, or replace it deliberately.
      const current = await getServiceConnection(owner.id, ctx.entry.id as never);
      if (current && current.url && current.url !== url && ctx.values.replaceConnection !== 'true') {
        return {
          status: 'blocked',
          reason:
            `${ctx.entry.label} is already connected to ${current.url}. Installing would repoint it to ` +
            `${url}. Keep the existing connection, or reinstall with "replace connection" to change it.`,
          completeAt: current.url,
        };
      }

      // The OWNER's row: it carries the URL and IS the instance. Members get their own rows later, with
      // a null url that inherits this one — see members.ts and the service_connections schema.
      await saveServiceConnection({
        userId: owner.id,
        service: ctx.entry.id as never,
        url,
        username: ctx.values.username || null,
        secret: ctx.values.secret || null,
        path: ctx.values.path || null,
      });

      ctx.log(`connection saved for ${ctx.entry.label}`);
      return { status: 'done' };
    },

    async applySchema(ctx: StepContext) {
      // A no-op today, and honestly so. Every sidecar's tables still ship in the platform's single
      // Drizzle schema and arrive together via `bun db:push`, so by the time an install runs they
      // already exist. The step is in the plan because per-sidecar schema is the direction — a
      // marketplace plugin cannot ship a table into a schema it does not own — and adding the step
      // later would mean revisiting every install that had already recorded its progress without it.
      ctx.log('schema: already present (platform-wide schema; per-sidecar schema is a later phase)');
    },

    async publishAssets(ctx: StepContext) {
      // Every install, not just provisioned ones: an `existing` Photos still needs its icon in the dock.
      // The template directory is where a sidecar ships its assets, and a sidecar with none is the
      // common case rather than an error.
      const template = ctx.entry.composeTemplate ?? ctx.entry.id;
      const published = await publishAssets(ctx.entry.id, join(TEMPLATES_DIR, template));
      ctx.log(published ? `assets published to /plugins/${ctx.entry.id}/` : 'assets: none shipped');
    },

    async startProcess(ctx: StepContext) {
      const result = await startProcess(ctx.entry.process, PLATFORM_DIR);
      if (!result.ok) throw new Error(`could not start ${ctx.entry.process}: ${result.error}`);
      ctx.log(`${ctx.entry.process} started`);
    },

    async provisionMembers(ctx: StepContext) {
      // Nothing implements MemberProvisioner yet — each sidecar will, beside its own code, rather than
      // in a switch here. Logged rather than silently skipped so that a member who cannot see a service
      // has a trail explaining why.
      ctx.log(`members: no provisioner registered for ${ctx.entry.id} yet — members will need manual access`);
    },
  };
}

/** Where a provisioned service's compose file lives, for the caller to record. */
export const composeDirFor = (sidecarId: string): string => serviceDir(sidecarId);
