/**
 * LlmService — CRUD over `Llm` rows plus credential resolution.
 *
 * Credentials are stored by reference: the row carries `(apiKeySecretId,
 * apiKeySecretKey)`. Callers that need the raw key (the inference proxy, once
 * it lands in Phase 2) call `resolveApiKey()`, which reads through the
 * SecretService (whose own backend dispatch transparently hits plaintext or
 * OpenBao as configured).
 *
 * The CLI/API accepts `apiKeyRef: { name, key }` — the service translates
 * that to the FK pair.
 */
import type { Llm } from '@prisma/client';
import type { ILlmRepository } from '../repositories/llm.repository.js';
import type { SecretService } from './secret.service.js';
import type { LlmAdapterRegistry } from './llm/dispatcher.js';
import type { InferContext } from './llm/types.js';
import {
  CreateLlmSchema,
  UpdateLlmSchema,
  type CreateLlmInput,
  type ApiKeyRef,
} from '../validation/llm.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';

/** Dependencies for auth verification at create/update time. */
export interface LlmServiceDeps {
  /** Adapter registry to run the auth probe. Optional in tests / bootstrap. */
  adapters?: LlmAdapterRegistry;
  /** Logger for unreachable/unexpected probe outcomes. */
  log?: { warn: (msg: string) => void };
}

/** Thrown when the auth probe fails decisively (401/403 from upstream). */
export class LlmAuthVerificationError extends Error {
  constructor(public readonly status: number, public readonly body: string, message: string) {
    super(message);
    this.name = 'LlmAuthVerificationError';
  }
}

/** Shape returned by API layer — merges DB row with a human-readable apiKeyRef. */
export interface LlmView {
  id: string;
  name: string;
  type: string;
  model: string;
  url: string;
  tier: string;
  description: string;
  apiKeyRef: ApiKeyRef | null;
  extraConfig: Record<string, unknown>;
  /**
   * v4: explicit pool key. NULL means "pool of 1" — the row's effective
   * pool key falls back to its own `name`. Multiple rows sharing a non-null
   * `poolName` stack into one load-balanced pool that the chat dispatcher
   * expands at request time.
   */
  poolName: string | null;
  /**
   * v7: per-user RBAC scoping. NULL `ownerId` on legacy rows means
   * "no recorded owner"; treated as public for visibility checks.
   */
  ownerId: string | null;
  /**
   * v7: visibility scope. 'public' = anyone with the resource grant;
   * 'private' = owner + explicit name-scoped RBAC bindings only.
   * mcplocal virtuals default to 'private' on register.
   */
  visibility: 'public' | 'private';
  // Virtual-provider lifecycle (kind defaults to 'public' for legacy rows).
  kind: 'public' | 'virtual';
  status: 'active' | 'inactive' | 'hibernating';
  lastHeartbeatAt: Date | null;
  inactiveSince: Date | null;
  version: number;
  createdAt: Date;
  updatedAt: Date;
}

/**
 * v4: compute the effective pool key for an Llm. When `poolName` is
 * explicitly set we use it; otherwise we fall back to the row's own
 * `name` so a non-pooled Llm dispatches as a "pool of 1" without any
 * branching at the call site.
 */
export function effectivePoolName(row: { name: string; poolName: string | null }): string {
  return row.poolName !== null && row.poolName !== '' ? row.poolName : row.name;
}

/**
 * v7: visibility scope for the current request. The route layer
 * computes this from `request.userId` + `RbacService.getAllowedScope`
 * and passes it down to LlmService for filtering. When `wildcard` is
 * true (admin / resource-wide `view:llms`), no filter applies — every
 * row is visible regardless of owner / private flag. When false, the
 * filter is `visibility='public' OR ownerId=userId OR name in allowedNames`.
 *
 * `null` Viewer means "skip the v7 filter entirely" — used by internal
 * callers (cron sweeps, audit collectors) and tests that don't have a
 * request context.
 */
export interface Viewer {
  userId: string;
  /** True when the caller has resource-wide `view:llms` (or admin). */
  wildcard: boolean;
  /** Name-scoped grants the caller holds (e.g. `view:llms:vllm-alice`). */
  allowedNames: Set<string>;
}

/**
 * v7: shared predicate. A row is visible to the viewer when:
 *   - visibility is public AND row passes RBAC layer above (caller already has resource grant), OR
 *   - viewer is null (internal call, no filter), OR
 *   - viewer has wildcard grant, OR
 *   - viewer is the owner, OR
 *   - the row's name is in viewer.allowedNames (name-scoped grant).
 *
 * Pure function so service tests can exercise it directly without a
 * full mock RbacService.
 */
export function isLlmVisibleTo(
  row: { name: string; ownerId: string | null; visibility: string },
  viewer: Viewer | null,
): boolean {
  if (viewer === null) return true;
  if (viewer.wildcard) return true;
  if (row.visibility !== 'private') return true;
  if (row.ownerId !== null && row.ownerId === viewer.userId) return true;
  if (viewer.allowedNames.has(row.name)) return true;
  return false;
}

export class LlmService {
  constructor(
    private readonly repo: ILlmRepository,
    private readonly secrets: SecretService,
    private readonly verifyDeps: LlmServiceDeps = {},
  ) {}

  async list(viewer: Viewer | null = null): Promise<LlmView[]> {
    const rows = await this.repo.findAll();
    const visible = rows.filter((r) => isLlmVisibleTo(r, viewer));
    return Promise.all(visible.map((r) => this.toView(r)));
  }

  async getById(id: string, viewer: Viewer | null = null): Promise<LlmView> {
    const row = await this.repo.findById(id);
    if (row === null) throw new NotFoundError(`Llm not found: ${id}`);
    // 404 (not 403) on a foreign-private row prevents id-enumeration —
    // identical shape to the chat-thread + inference-task routes.
    if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${id}`);
    return this.toView(row);
  }

  async getByName(name: string, viewer: Viewer | null = null): Promise<LlmView> {
    const row = await this.repo.findByName(name);
    if (row === null) throw new NotFoundError(`Llm not found: ${name}`);
    if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${name}`);
    return this.toView(row);
  }

  /**
   * v4: members of an effective pool. The pool is defined as:
   *   `WHERE poolName = $1 OR (poolName IS NULL AND name = $1)`
   * — solo Llms (poolName=null) are addressable by their own name as a
   * pool of 1; explicit pool members share a `poolName`. Returns raw
   * `Llm` rows because the chat dispatcher needs per-row api key
   * resolution to build its candidate list — `toView` would force a
   * round-trip through SecretService for every row, which is wasted
   * work for a pool of N where most members are about to be skipped.
   */
  async findByPoolName(poolName: string): Promise<Llm[]> {
    return this.repo.findByPoolName(poolName);
  }

  /**
   * v4: API/CLI-facing version of `findByPoolName` that returns full
   * `LlmView`s (apiKeyRef resolved via SecretService). Used by the
   * `GET /api/v1/llms/:name/members` route and `mcpctl describe llm` —
   * both of which want the full row shape for display.
   */
  async listPoolMembers(poolName: string): Promise<LlmView[]> {
    const rows = await this.repo.findByPoolName(poolName);
    return Promise.all(rows.map((r) => this.toView(r)));
  }

  async create(input: unknown, opts: { skipAuthCheck?: boolean } = {}): Promise<LlmView> {
    const data = CreateLlmSchema.parse(input);
    const existing = await this.repo.findByName(data.name);
    if (existing !== null) throw new ConflictError(`Llm already exists: ${data.name}`);

    const apiKeyFields = await this.resolveApiKeyRefToIds(data.apiKeyRef);

    // Auth probe: catch wrong tokens / wrong URLs at registration time, not
    // at first chat. Skipped when there's no key (probe would be meaningless)
    // or the caller explicitly opted out (e.g. wiring config before infra
    // exists). The probe is also skipped when no adapters registry was
    // injected — keeps tests + bootstrap simple.
    if (!opts.skipAuthCheck && apiKeyFields.id !== null && this.verifyDeps.adapters !== undefined) {
      await this.runAuthProbe({
        name: data.name,
        type: data.type,
        model: data.model,
        url: data.url ?? '',
        apiKeyRef: data.apiKeyRef ?? null,
        extraConfig: data.extraConfig,
      });
    }

    const row = await this.repo.create({
      name: data.name,
      type: data.type,
      model: data.model,
      url: data.url ?? '',
      tier: data.tier,
      description: data.description,
      apiKeySecretId: apiKeyFields.id,
      apiKeySecretKey: apiKeyFields.key,
      extraConfig: data.extraConfig,
      ...(data.poolName !== undefined ? { poolName: data.poolName } : {}),
    });
    return this.toView(row);
  }

  async update(id: string, input: unknown, opts: { skipAuthCheck?: boolean } = {}): Promise<LlmView> {
    const data = UpdateLlmSchema.parse(input);
    const before = await this.getById(id);

    const updateFields: Parameters<ILlmRepository['update']>[1] = {};
    if (data.model !== undefined) updateFields.model = data.model;
    if (data.url !== undefined) updateFields.url = data.url;
    if (data.tier !== undefined) updateFields.tier = data.tier;
    if (data.description !== undefined) updateFields.description = data.description;
    if (data.extraConfig !== undefined) updateFields.extraConfig = data.extraConfig;
    // poolName: null → explicit unlink (revert to "pool of 1"); string → set; undefined → leave alone.
    if (data.poolName !== undefined) updateFields.poolName = data.poolName;

    // apiKeyRef: null → explicit unlink; object → replace; undefined → leave alone.
    if (data.apiKeyRef !== undefined) {
      if (data.apiKeyRef === null) {
        updateFields.apiKeySecretId = null;
        updateFields.apiKeySecretKey = null;
      } else {
        const resolved = await this.resolveApiKeyRefToIds(data.apiKeyRef);
        updateFields.apiKeySecretId = resolved.id;
        updateFields.apiKeySecretKey = resolved.key;
      }
    }

    // Auth probe runs whenever any field that affects auth (apiKeyRef OR url)
    // is changing, OR whenever the caller asks via skipAuthCheck=false. The
    // probe uses the post-update view (new key + new url + same type/model).
    const authAffectingChange = data.apiKeyRef !== undefined || data.url !== undefined;
    const willHaveKey = data.apiKeyRef === null
      ? false
      : data.apiKeyRef !== undefined || before.apiKeyRef !== null;
    if (authAffectingChange && !opts.skipAuthCheck && willHaveKey && this.verifyDeps.adapters !== undefined) {
      await this.runAuthProbe({
        name: before.name,
        type: before.type,
        model: data.model ?? before.model,
        url: data.url ?? before.url,
        apiKeyRef: data.apiKeyRef === undefined ? before.apiKeyRef : data.apiKeyRef,
        extraConfig: data.extraConfig ?? before.extraConfig,
      });
    }

    const row = await this.repo.update(id, updateFields);
    return this.toView(row);
  }

  /**
   * Run a cheap auth probe against the upstream provider. Throws
   * `LlmAuthVerificationError` on a definitive auth failure (401/403).
   * Logs and swallows transient network/unexpected errors — those are not
   * fatal at registration time.
   */
  private async runAuthProbe(snap: {
    name: string;
    type: string;
    model: string;
    url: string;
    apiKeyRef: ApiKeyRef | null;
    extraConfig: Record<string, unknown>;
  }): Promise<void> {
    if (snap.apiKeyRef === null) return;
    if (this.verifyDeps.adapters === undefined) return;
    let apiKey: string;
    try {
      const secret = await this.secrets.getByName(snap.apiKeyRef.name);
      const data = await this.secrets.resolveData(secret);
      const v = data[snap.apiKeyRef.key];
      if (v === undefined || v === '') {
        throw new LlmAuthVerificationError(0, '', `Llm '${snap.name}' apiKeyRef points at empty secret data`);
      }
      apiKey = v;
    } catch (err) {
      if (err instanceof LlmAuthVerificationError) throw err;
      // Secret resolution failure — bail with a clean error rather than
      // letting it bubble as a generic 500.
      throw new LlmAuthVerificationError(0, '', `Llm '${snap.name}' apiKeyRef could not be resolved: ${(err as Error).message}`);
    }
    let adapter;
    try {
      adapter = this.verifyDeps.adapters.get(snap.type);
    } catch (err) {
      // Provider type unsupported by the registry — that's a config error,
      // surface it now.
      throw new LlmAuthVerificationError(0, '', `Llm '${snap.name}' type '${snap.type}' has no adapter: ${(err as Error).message}`);
    }
    const ctx: InferContext = {
      body: { model: snap.model, messages: [] },
      modelOverride: snap.model,
      apiKey,
      url: snap.url,
      extraConfig: snap.extraConfig,
    };
    const result = await adapter.verifyAuth(ctx);
    if (result.ok) return;
    if (result.reason === 'auth') {
      throw new LlmAuthVerificationError(
        result.status,
        result.body,
        `Llm '${snap.name}' auth check failed: ${snap.url || '(default URL)'} returned HTTP ${String(result.status)}. ` +
        `Body: ${result.body.slice(0, 400)}`,
      );
    }
    // unreachable / unexpected — warn but allow registration. The user might
    // be wiring config before the upstream is reachable, or hitting a
    // proxy that doesn't expose /v1/models.
    const reason = result.reason === 'unreachable' ? `unreachable (${result.error})` : `HTTP ${String(result.status)} (${result.body.slice(0, 200)})`;
    this.verifyDeps.log?.warn(
      `Llm '${snap.name}': auth probe inconclusive — ${reason}. Registration succeeded; first inference call will surface any real issue.`,
    );
  }

  async delete(id: string): Promise<void> {
    await this.getById(id);
    await this.repo.delete(id);
  }

  /**
   * Return the raw API key string for a given Llm. Called by the inference
   * proxy in Phase 2. Throws NotFoundError if the Llm has no apiKeyRef, or the
   * referenced secret/key doesn't exist.
   */
  async resolveApiKey(llmName: string): Promise<string> {
    const row = await this.repo.findByName(llmName);
    if (row === null) throw new NotFoundError(`Llm not found: ${llmName}`);
    if (row.apiKeySecretId === null || row.apiKeySecretKey === null) {
      throw new NotFoundError(`Llm '${llmName}' has no apiKeyRef configured`);
    }
    const secret = await this.secrets.getById(row.apiKeySecretId);
    const data = await this.secrets.resolveData(secret);
    const value = data[row.apiKeySecretKey];
    if (value === undefined) {
      throw new NotFoundError(`Secret '${secret.name}' has no key '${row.apiKeySecretKey}'`);
    }
    return value;
  }

  private async resolveApiKeyRefToIds(ref: ApiKeyRef | undefined): Promise<{ id: string | null; key: string | null }> {
    if (ref === undefined) return { id: null, key: null };
    const secret = await this.secrets.getByName(ref.name);
    return { id: secret.id, key: ref.key };
  }

  private async toView(row: Llm): Promise<LlmView> {
    let apiKeyRef: ApiKeyRef | null = null;
    if (row.apiKeySecretId !== null && row.apiKeySecretKey !== null) {
      const secret = await this.secrets.getById(row.apiKeySecretId).catch(() => null);
      if (secret !== null) {
        apiKeyRef = { name: secret.name, key: row.apiKeySecretKey };
      }
    }
    return {
      id: row.id,
      name: row.name,
      type: row.type,
      model: row.model,
      url: row.url,
      tier: row.tier,
      description: row.description,
      apiKeyRef,
      extraConfig: row.extraConfig as Record<string, unknown>,
      poolName: row.poolName,
      ownerId: row.ownerId,
      visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private',
      kind: row.kind,
      status: row.status,
      lastHeartbeatAt: row.lastHeartbeatAt,
      inactiveSince: row.inactiveSince,
      version: row.version,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    };
  }

  // ── Backup/restore helpers ──

  async upsertByName(input: CreateLlmInput): Promise<LlmView> {
    const existing = await this.repo.findByName(input.name);
    if (existing !== null) {
      return this.update(existing.id, input);
    }
    return this.create(input);
  }

  async deleteByName(name: string): Promise<void> {
    const row = await this.repo.findByName(name);
    if (row === null) return;
    await this.delete(row.id);
  }
}
