import type { McpInstance, McpServer } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js';
import { resolveServerEnv, type SecretResolver } from './env-resolver.js';

/** Runner images for package-based MCP servers, keyed by runtime name. */
const RUNNER_IMAGES: Record<string, string> = {
  node: process.env['MCPD_NODE_RUNNER_IMAGE'] ?? 'mysources.co.uk/michal/mcpctl-node-runner:latest',
  python: process.env['MCPD_PYTHON_RUNNER_IMAGE'] ?? 'mysources.co.uk/michal/mcpctl-python-runner:latest',
};

/** Network for MCP server containers (matches docker-compose mcp-servers network). */
const MCP_SERVERS_NETWORK = process.env['MCPD_MCP_NETWORK'] ?? 'mcp-servers';

/**
 * Backoff schedule for instance startup failures (env resolution, container
 * creation, etc). Mirrors Kubernetes-style escalation: fast retries for
 * transient hiccups, then a longer pause once it's clear something is
 * persistently wrong.
 *
 * The retry state lives on `McpInstance.metadata` (no schema migration
 * needed) and is preserved across reconcile cycles by the in-place
 * `retryInstance` path so attemptCount actually accumulates.
 */
const FAST_RETRY_MS = 30_000;          // first 5 attempts: 30s apart
const SLOW_RETRY_MS = 5 * 60_000;      // afterwards: 5 minutes
const MAX_FAST_RETRIES = 5;

interface RetryMetadata {
  error?: string;
  attemptCount?: number;
  lastAttemptAt?: string;
  nextRetryAt?: string;
  [k: string]: unknown;
}

function readRetryMeta(instance: McpInstance): RetryMetadata {
  return (instance.metadata ?? {}) as RetryMetadata;
}

function nextDelayMs(attemptCount: number): number {
  return attemptCount <= MAX_FAST_RETRIES ? FAST_RETRY_MS : SLOW_RETRY_MS;
}

export class InvalidStateError extends Error {
  readonly statusCode = 409;
  constructor(message: string) {
    super(message);
    this.name = 'InvalidStateError';
  }
}

export class InstanceService {
  constructor(
    private instanceRepo: IMcpInstanceRepository,
    private serverRepo: IMcpServerRepository,
    private orchestrator: McpOrchestrator,
    private secretResolver?: SecretResolver,
  ) {}

  async list(serverId?: string): Promise<McpInstance[]> {
    return this.instanceRepo.findAll(serverId);
  }

  async getById(id: string): Promise<McpInstance> {
    const instance = await this.instanceRepo.findById(id);
    if (!instance) throw new NotFoundError(`Instance '${id}' not found`);
    return instance;
  }

  /**
   * Sync instance statuses with actual container state.
   * Detects crashed/stopped containers and marks them ERROR.
   */
  async syncStatus(): Promise<void> {
    const instances = await this.instanceRepo.findAll();
    for (const inst of instances) {
      if ((inst.status === 'RUNNING' || inst.status === 'STARTING') && inst.containerId) {
        try {
          const info = await this.orchestrator.inspectContainer(inst.containerId);

          if (info.state === 'stopped' || info.state === 'error') {
            // Container died — get last logs for error context
            let errorMsg = `Container ${info.state}`;
            try {
              const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 });
              const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop();
              if (lastLog) errorMsg = lastLog;
            } catch { /* best-effort */ }
            await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
              metadata: { error: errorMsg },
            });
          } else if (info.state === 'starting' && inst.status === 'RUNNING') {
            // Pod went back to starting (e.g. CrashLoopBackOff restart)
            await this.instanceRepo.updateStatus(inst.id, 'STARTING', {});
          } else if (info.state === 'running' && inst.status === 'STARTING') {
            // Pod became ready — promote to RUNNING
            await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {});
          }
        } catch {
          // Container gone entirely
          await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
            metadata: { error: 'Container not found' },
          });
        }
      }
    }
  }

  /**
   * Reconcile instances for a server to match desired replica count.
   * - Syncs container statuses first (detect crashed containers)
   * - If fewer running instances than replicas: start new ones
   * - If more running instances than replicas: remove excess (oldest first)
   */
  async reconcile(serverId: string): Promise<McpInstance[]> {
    const server = await this.serverRepo.findById(serverId);
    if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);

    // Sync container statuses before counting active instances
    await this.syncStatus();

    const instances = await this.instanceRepo.findAll(serverId);
    const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
    const desired = server.replicas;

    if (active.length < desired) {
      // Scale up
      const toStart = desired - active.length;
      for (let i = 0; i < toStart; i++) {
        await this.startOne(serverId);
      }
    } else if (active.length > desired) {
      // Scale down — remove oldest first
      const excess = active
        .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
        .slice(0, active.length - desired);
      for (const inst of excess) {
        await this.removeOne(inst);
      }
    }

    return this.instanceRepo.findAll(serverId);
  }

  /**
   * Reconcile ALL servers — the operator loop.
   *
   * For every server with replicas > 0, ensures the correct number of
   * healthy instances exist. ERROR instances are not blindly recreated:
   * within their `nextRetryAt` window they're left alone (and counted
   * against the replica budget so we don't churn replacements while one
   * is in backoff); past their window they're retried in-place via
   * `retryInstance` so attemptCount accumulates and backoff escalates
   * correctly.
   */
  async reconcileAll(): Promise<{ reconciled: number; errors: string[] }> {
    await this.syncStatus();

    const servers = await this.serverRepo.findAll();
    let reconciled = 0;
    const errors: string[] = [];

    const now = Date.now();

    for (const server of servers) {
      if (server.replicas <= 0) continue;

      try {
        const instances = await this.instanceRepo.findAll(server.id);
        const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
        const errored = instances.filter((i) => i.status === 'ERROR');

        // Partition ERROR instances by whether their backoff window has elapsed.
        const dueForRetry: McpInstance[] = [];
        const stillWaiting: McpInstance[] = [];
        for (const inst of errored) {
          const meta = readRetryMeta(inst);
          const ts = meta.nextRetryAt ? Date.parse(meta.nextRetryAt) : 0;
          if (Number.isNaN(ts) || ts <= now) {
            dueForRetry.push(inst);
          } else {
            stillWaiting.push(inst);
          }
        }

        // Retry elapsed ones in-place. This preserves attemptCount across
        // attempts so the 30s × 5 → 5min schedule actually escalates.
        for (const inst of dueForRetry) {
          await this.retryInstance(inst);
        }

        // Scale up only if we don't already have enough live attempts.
        // Live attempts = currently-running OR -starting + still-waiting
        // (in backoff) + just-retried (now STARTING via retryInstance).
        // Counting waiting + retried against the budget prevents tight
        // create-fail-create churn while previous attempts work through
        // their backoff schedule.
        const toStart = server.replicas - active.length - stillWaiting.length - dueForRetry.length;
        if (toStart > 0) {
          for (let i = 0; i < toStart; i++) {
            await this.startOne(server.id);
          }
        }
        if (toStart > 0 || dueForRetry.length > 0) {
          reconciled++;
        }
      } catch (err) {
        errors.push(`${server.name}: ${err instanceof Error ? err.message : String(err)}`);
      }
    }

    return { reconciled, errors };
  }

  /**
   * Remove an instance (stop container + delete DB record).
   * Does NOT reconcile — caller should reconcile after if needed.
   */
  async remove(id: string): Promise<{ serverId: string }> {
    const instance = await this.getById(id);

    if (instance.containerId) {
      try {
        await this.orchestrator.stopContainer(instance.containerId);
      } catch {
        // Container may already be stopped
      }
      try {
        await this.orchestrator.removeContainer(instance.containerId, true);
      } catch {
        // Container may already be gone
      }
    }

    await this.instanceRepo.delete(id);
    return { serverId: instance.serverId };
  }

  /**
   * Remove all instances for a server (used before server deletion).
   * Stops all containers so Prisma cascade only cleans up DB records.
   */
  async removeAllForServer(serverId: string): Promise<void> {
    const instances = await this.instanceRepo.findAll(serverId);
    for (const inst of instances) {
      if (inst.containerId) {
        try {
          await this.orchestrator.stopContainer(inst.containerId);
        } catch {
          // best-effort
        }
        try {
          await this.orchestrator.removeContainer(inst.containerId, true);
        } catch {
          // best-effort
        }
      }
    }
  }

  async inspect(id: string): Promise<ContainerInfo> {
    const instance = await this.getById(id);
    if (!instance.containerId) {
      throw new InvalidStateError(`Instance '${id}' has no container`);
    }
    return this.orchestrator.inspectContainer(instance.containerId);
  }

  async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
    const instance = await this.getById(id);
    if (!instance.containerId) {
      return { stdout: '', stderr: '' };
    }

    return this.orchestrator.getContainerLogs(instance.containerId, opts);
  }

  /**
   * Start a single instance for a server. Creates a fresh `STARTING` row
   * and hands off to `attemptStart` for the env+container work. On
   * failure, `attemptStart` marks the row `ERROR` with a backoff-aware
   * `nextRetryAt`; the reconciler picks it up later via `retryInstance`.
   */
  private async startOne(serverId: string): Promise<McpInstance> {
    const server = await this.serverRepo.findById(serverId);
    if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);

    // External servers don't need container management
    if (server.externalUrl) {
      return this.instanceRepo.create({
        serverId,
        status: 'RUNNING',
        metadata: { external: true, url: server.externalUrl },
      });
    }

    const instance = await this.instanceRepo.create({
      serverId,
      status: 'STARTING',
    });
    return this.attemptStart(instance, server);
  }

  /**
   * Re-attempt a previously-errored instance in place, preserving its
   * `attemptCount` so the backoff schedule escalates correctly. Called
   * by `reconcileAll` for ERROR instances whose `nextRetryAt` has elapsed.
   */
  private async retryInstance(instance: McpInstance): Promise<McpInstance> {
    const server = await this.serverRepo.findById(instance.serverId);
    if (!server) {
      // Server was deleted underneath us — nothing to retry against.
      return this.markInstanceError(instance, 'Server no longer exists');
    }

    if (server.externalUrl) {
      // External servers don't need a container; the URL is the contract.
      return this.instanceRepo.updateStatus(instance.id, 'RUNNING', {
        metadata: { external: true, url: server.externalUrl },
      });
    }

    // Reset transient fields but keep retry counters via the metadata
    // passed through `attemptStart` → `markInstanceError`.
    await this.instanceRepo.updateStatus(instance.id, 'STARTING', {});
    const refreshed = (await this.instanceRepo.findById(instance.id)) ?? instance;
    return this.attemptStart(refreshed, server);
  }

  /**
   * Run the env-resolution + container-creation steps for a STARTING
   * instance. On any failure, mark the instance `ERROR` with structured
   * retry metadata. Used by both initial start (`startOne`) and retry
   * (`retryInstance`).
   */
  private async attemptStart(
    instance: McpInstance,
    server: McpServer,
  ): Promise<McpInstance> {
    // Determine image + command based on server config:
    // 1. Explicit dockerImage → use as-is
    // 2. packageName → use runtime-specific runner image (node/python/go/...)
    // 3. Fallback → server name (legacy)
    let image: string;
    let pkgCommand: string[] | undefined;

    if (server.dockerImage) {
      image = server.dockerImage;
    } else if (server.packageName) {
      const runtime = (server.runtime as string | null) ?? 'node';
      image = RUNNER_IMAGES[runtime] ?? RUNNER_IMAGES['node']!;
      // Runner entrypoint handles package execution (npx -y / uvx / go run)
      const serverCommand = server.command as string[] | null;
      pkgCommand = [server.packageName, ...(serverCommand ?? [])];
    } else {
      image = server.name;
    }

    try {
      const spec: ContainerSpec = {
        image,
        name: `mcpctl-${server.name}-${instance.id}`,
        hostPort: null,
        network: MCP_SERVERS_NETWORK,
        labels: {
          'mcpctl.server-id': server.id,
          'mcpctl.instance-id': instance.id,
        },
      };
      if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
        spec.containerPort = server.containerPort ?? 3000;
      }
      // Package-based servers: command = [packageName, ...args] (entrypoint handles execution)
      // Docker-image servers: use explicit command if provided
      if (pkgCommand) {
        spec.command = pkgCommand;
      } else {
        const command = server.command as string[] | null;
        if (command) {
          spec.command = command;
        }
      }

      // Resolve env vars from inline values and secret refs.
      //
      // Failure here is FATAL for the start attempt: a container that
      // boots without its declared secrets will silently mis-behave (we
      // saw this with gitea-mcp-server starting up with an empty
      // GITEA_ACCESS_TOKEN when OpenBao was unreachable, then reporting
      // "healthy" while every authed call failed). Marking the instance
      // ERROR with a backoff-aware nextRetryAt is honest; the reconciler
      // will retry it in-place on the next tick whose nextRetryAt has
      // elapsed. Optional/missing env vars should be modeled as `value: ""`
      // entries on the server, not as silent secret-resolution failures.
      if (this.secretResolver) {
        try {
          const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
          if (Object.keys(resolvedEnv).length > 0) {
            spec.env = resolvedEnv;
          }
        } catch (envErr) {
          const msg = envErr instanceof Error ? envErr.message : String(envErr);
          return this.markInstanceError(instance, `secret resolution failed: ${msg}`);
        }
      }

      // Pull image if not available locally
      try {
        await this.orchestrator.pullImage(image);
      } catch {
        // Image may already be available locally
      }

      const containerInfo = await this.orchestrator.createContainer(spec);

      const updateFields: { containerId: string; port?: number } = {
        containerId: containerInfo.containerId,
      };
      if (containerInfo.port !== undefined) {
        updateFields.port = containerInfo.port;
      }

      // Set STARTING — syncStatus will promote to RUNNING once the container is actually ready
      return this.instanceRepo.updateStatus(instance.id, 'STARTING', updateFields);
    } catch (err) {
      return this.markInstanceError(
        instance,
        err instanceof Error ? err.message : String(err),
      );
    }
  }

  /**
   * Mark an instance ERROR with a backoff-aware retry schedule. The
   * `attemptCount` accumulates across retries (preserved by
   * `retryInstance` which reuses the same row), so the schedule
   * actually escalates: 30s × 5 → 5min thereafter.
   */
  private async markInstanceError(
    instance: McpInstance,
    error: string,
  ): Promise<McpInstance> {
    const meta = readRetryMeta(instance);
    const attemptCount = (typeof meta.attemptCount === 'number' ? meta.attemptCount : 0) + 1;
    const delayMs = nextDelayMs(attemptCount);
    const now = new Date();
    const nextRetryAt = new Date(now.getTime() + delayMs).toISOString();
    return this.instanceRepo.updateStatus(instance.id, 'ERROR', {
      metadata: {
        ...meta,
        error,
        attemptCount,
        lastAttemptAt: now.toISOString(),
        nextRetryAt,
      },
    });
  }

  /** Stop and remove a single instance. */
  private async removeOne(instance: McpInstance): Promise<void> {
    if (instance.containerId) {
      try {
        await this.orchestrator.stopContainer(instance.containerId);
      } catch { /* best-effort */ }
      try {
        await this.orchestrator.removeContainer(instance.containerId, true);
      } catch { /* best-effort */ }
    }
    await this.instanceRepo.delete(instance.id);
  }
}
