/**
 * Model & credential config routes:
 * GET|PUT /api/projects/:p/models, PUT /api/projects/:p/models/default,
 * POST /api/projects/:p/models/test (the model reference
 * `(provider, modelId)` is sent as a pair in the request body, avoiding URL-encoding
 * issues). Any member can read (api_key is masked); only the owner can modify or test.
 */
import { Hono } from "hono";
import type {
  DefaultModelResponse,
  ModelRefDto,
  ModelsUpdateRequest,
  ModelTestRequest,
  ModelUpdateEntry,
  ServerEvent,
} from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";

/**
 * Live unlock for auth-dead composers: after a models/credential update, publish
 * `credentials_updated` to every EXISTING channel of this Project's Sessions (`peek` —
 * never creates channels: a tab that isn't subscribed learns the same fact from the models
 * response's `updatedAt` when it next loads).
 */
function publishCredentialsUpdated(deps: AppDeps, projectId: string): void {
  const event: ServerEvent = { type: "credentials_updated" };
  for (const row of deps.sessionsRepo.listByProject(projectId)) {
    deps.channels.peek(row.sessionId)?.publish(event, "server_event");
  }
}

/** Validate a paired reference object ({ provider, modelId }); shape mismatch throws 400. */
function parseRef(value: unknown, label: string): ModelRefDto {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw badRequest(`${label} must be a { provider, modelId } object.`);
  }
  const r = value as Record<string, unknown>;
  return {
    provider: requireString(r, "provider", { minLen: 1, maxLen: 64, label: `${label}.provider` }),
    modelId: requireString(r, "modelId", { minLen: 1, maxLen: 200, label: `${label}.modelId` }),
  };
}

/** Validate the PUT request body and shape it into a ModelsUpdateRequest (rejects any shape errors). */
function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
  if (!Array.isArray(body.models)) throw badRequest("models must be an array.");
  const models: ModelUpdateEntry[] = body.models.map((item, i) => {
    if (item === null || typeof item !== "object" || Array.isArray(item)) {
      throw badRequest(`models[${i}] must be an object.`);
    }
    const m = item as Record<string, unknown>;
    const entry: ModelUpdateEntry = {
      provider: requireString(m, "provider", {
        minLen: 1,
        maxLen: 64,
        label: `models[${i}].provider`,
      }),
      modelId: requireString(m, "modelId", {
        minLen: 1,
        maxLen: 200,
        label: `models[${i}].modelId`,
      }),
    };
    if (m.displayName !== undefined) {
      if (typeof m.displayName !== "string" || m.displayName.length > 100) {
        throw badRequest(`models[${i}].displayName must be a string of at most 100 characters.`);
      }
      if (m.displayName) entry.displayName = m.displayName;
    }
    // A key change (either the provider group or the upstream id) goes through renamedFrom's paired old reference; unknown fields are ignored.
    if (m.renamedFrom !== undefined) {
      entry.renamedFrom = parseRef(m.renamedFrom, `models[${i}].renamedFrom`);
    }
    if (m.contextWindow !== undefined) {
      if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) {
        throw badRequest(`models[${i}].contextWindow must be a positive number.`);
      }
      entry.contextWindow = m.contextWindow;
    }
    if (m.clientType !== undefined) {
      if (typeof m.clientType !== "string" || m.clientType.length > 64) {
        throw badRequest(`models[${i}].clientType must be a string of at most 64 characters.`);
      }
      // An empty string is treated as "unspecified", leaving AgentHub to infer it from modelId.
      if (m.clientType) entry.clientType = m.clientType;
    }
    if (m.vision !== undefined) {
      if (typeof m.vision !== "boolean") {
        throw badRequest(`models[${i}].vision must be a boolean.`);
      }
      entry.vision = m.vision;
    }
    if (m.maxTokens !== undefined) {
      if (typeof m.maxTokens !== "number" || !Number.isInteger(m.maxTokens) || m.maxTokens <= 0) {
        throw badRequest(`models[${i}].maxTokens must be a positive integer.`);
      }
      entry.maxTokens = m.maxTokens;
    }
    if (m.pricing !== undefined) {
      const p = m.pricing as Record<string, unknown>;
      if (p === null || typeof p !== "object" || Array.isArray(p)) {
        throw badRequest(`models[${i}].pricing must be an object.`);
      }
      for (const key of ["cacheRead", "cacheWrite", "output"] as const) {
        const v = p[key];
        if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
          throw badRequest(`models[${i}].pricing.${key} must be a non-negative number.`);
        }
      }
      entry.pricing = {
        cacheRead: p.cacheRead as number,
        cacheWrite: p.cacheWrite as number,
        output: p.output as number,
      };
    }
    if (m.apiKey !== undefined) {
      if (typeof m.apiKey !== "string" || m.apiKey.length === 0) {
        throw badRequest(`models[${i}].apiKey must be a non-empty string.`);
      }
      entry.apiKey = m.apiKey;
    }
    if (m.clearApiKey !== undefined) {
      if (typeof m.clearApiKey !== "boolean") {
        throw badRequest(`models[${i}].clearApiKey must be a boolean.`);
      }
      entry.clearApiKey = m.clearApiKey;
    }
    if (m.baseUrl !== undefined) {
      if (m.baseUrl !== null && typeof m.baseUrl !== "string") {
        throw badRequest(`models[${i}].baseUrl must be a string or null.`);
      }
      entry.baseUrl = m.baseUrl as string | null;
    }
    return entry;
  });
  const req: ModelsUpdateRequest = { models };
  if (body.defaultModel !== undefined) {
    req.defaultModel = parseRef(body.defaultModel, "defaultModel");
  }
  if (body.visionModel !== undefined) {
    req.visionModel = parseRef(body.visionModel, "visionModel");
  }
  return req;
}

export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
  const app = new Hono<AppEnv>();

  app.get("/", async (c) => {
    // Defensive id validation (FD-4).
    const projectId = requireValidId(c, "projectId");
    deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
    return c.json(await deps.projectConfigService.getModels(projectId));
  });

  app.put("/", async (c) => {
    const projectId = requireValidId(c, "projectId");
    deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
    const req = parseModelsUpdate(await readJson(c));
    const res = await deps.projectConfigService.updateModels(projectId, req);
    // Effective-value semantics (mirrors the vault route): no hot swap into a Task already
    // in flight, but every cached runtime in this Project is invalidated — the next Task on
    // any of its Sessions re-resumes and reads the new api_key / base_url. Without this, a
    // key edit would not reach a loaded Session until the 30-minute idle sweep or a restart.
    deps.manager.invalidateProjectRuntimes(projectId);
    // Live unlock: open tabs clear their auth-dead composer immediately (no reload needed).
    publishCredentialsUpdated(deps, projectId);
    return c.json(res);
  });

  // Narrow default-model switch (owner): flips the same top-level `default_model` the
  // whole-table PUT above maintains, without resending the table — project settings can
  // change the default without carrying credentials. The pair must name a configured
  // entry (400 otherwise, same rule as the whole-table route's defaultModel). No runtime
  // invalidation and no credentials_updated: existing Sessions pin their model at
  // creation, and no credential changes here.
  app.put("/default", async (c) => {
    const projectId = requireValidId(c, "projectId");
    deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
    const body = await readJson(c);
    const ref: ModelRefDto = {
      provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
      modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
    };
    const defaultModel = await deps.projectConfigService.setDefaultModelRef(projectId, ref);
    return c.json({ defaultModel } satisfies DefaultModelResponse);
  });

  // Connectivity test (owner): the model reference `(provider, modelId)` is sent as a pair
  // in the request body; sends one minimal request using that model's config. May include
  // not-yet-saved apiKey / baseUrl / clientType — when the model isn't in the config yet
  // (adding a custom model), everything is taken from the request body.
  app.post("/test", async (c) => {
    const projectId = requireValidId(c, "projectId");
    deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
    const body = await readJson(c);
    const req: ModelTestRequest = {
      provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
      modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
    };
    if (body.apiKey !== undefined) {
      if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
      if (body.apiKey) req.apiKey = body.apiKey;
    }
    if (body.clearApiKey !== undefined) {
      if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
      req.clearApiKey = body.clearApiKey;
    }
    if (body.speed !== undefined) {
      if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
      req.speed = body.speed;
    }
    // null = explicit clear (test against the draft, don't fall back to the stored value); empty string is treated as null.
    if (body.baseUrl !== undefined) {
      if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
        throw badRequest("baseUrl must be a string or null.");
      }
      req.baseUrl = body.baseUrl ? body.baseUrl : null;
    }
    if (body.clientType !== undefined) {
      if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
      if (body.clientType) req.clientType = body.clientType;
    }
    return c.json(await deps.projectConfigService.testModel(projectId, req));
  });

  return app;
}
