/**
 * Skill layer endpoints.
 *
 * Skills are the callable top of the memory stack (Phase 11). The
 * viewer uses these to render the skill library and to retire stale
 * skills when a user clicks "archive".
 */

import type { SkillDTO, SkillId } from "../../agent-contract/dto.js";
import type { ServerDeps } from "../types.js";
import { buildSingleFileZip } from "../../core/util/tiny-zip.js";
import { parseJson, writeError, type Routes } from "./registry.js";

export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void {
  routes.set("GET /api/v1/skills", async (ctx) => {
    const params = ctx.url.searchParams;
    const status = (params.get("status") as SkillDTO["status"] | null) ?? undefined;
    const q = (params.get("q") || "").trim().toLowerCase();
    const ownerAgentKind = params.get("ownerAgentKind") || undefined;
    const ownerProfileId = params.get("ownerProfileId") || undefined;
    // Viewer needs prev/next pagination — ask for one extra page so we
    // can tell the client whether there's more without a count query.
    const pageSize = limitOrUndefined(params.get("limit")) ?? 50;
    const offset = Math.max(0, Number(params.get("offset") ?? 0) || 0);
    let all = await deps.core.listSkills({
      status,
      limit: q ? 5000 : pageSize + offset + 1,
      ownerAgentKind,
      ownerProfileId,
    });
    if (q) {
      all = all.filter(
        (s) => s.name.toLowerCase().includes(q) || s.invocationGuide.toLowerCase().includes(q),
      );
    }
    const page = all.slice(offset, offset + pageSize);
    const hasMore = all.length > offset + pageSize;
    const total = q ? all.length : await deps.core.countSkills({
      status,
      ownerAgentKind,
      ownerProfileId,
    });
    return {
      skills: page,
      limit: pageSize,
      offset,
      total,
      nextOffset: hasMore ? offset + pageSize : undefined,
    };
  });

  routes.set("GET /api/v1/skills/get", async (ctx) => {
    const id = ctx.url.searchParams.get("id");
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const skill = await deps.core.getSkill(id as SkillId);
    if (skill === null) {
      writeError(ctx, 404, "not_found", `skill not found: ${id}`);
      return;
    }
    return skill;
  });

  routes.setPattern("GET /api/v1/skills/:id", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const skill = await deps.core.getSkill(id as SkillId);
    if (skill === null) {
      writeError(ctx, 404, "not_found", `skill not found: ${id}`);
      return;
    }
    return skill;
  });

  /**
   * `GET /api/v1/skills/:id/timeline` — evolution history for one skill.
   *
   * We assemble the timeline by walking the recent `api_logs` rows for
   * the `skill_generate` / `skill_evolve` tool names and keeping those
   * whose payload mentions this skill id. Mirrors the legacy
   * `memos-local-openclaw` "version history" table shown in the Skills
   * drawer.
   */
  routes.setPattern("GET /api/v1/skills/:id/timeline", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const picks = await Promise.all(
      ["skill_generate", "skill_evolve"].map((tool) =>
        deps.core.listApiLogs({ toolName: tool, limit: 500, offset: 0 }),
      ),
    );
    interface TimelineEntry {
      ts: number;
      kind: string;
      phase?: string;
      durationMs: number;
      success: boolean;
      summary?: string;
    }
    const entries: TimelineEntry[] = [];
    for (const res of picks) {
      for (const row of res.logs) {
        const out = safeJson(row.outputJson);
        const inp = safeJson(row.inputJson);
        const skillId =
          (out as { skillId?: unknown } | null)?.skillId ??
          (inp as { skillId?: unknown } | null)?.skillId;
        if (skillId !== id) continue;
        const kind =
          ((out as { kind?: unknown } | null)?.kind as string | undefined) ??
          row.toolName;
        const phase = (inp as { phase?: unknown } | null)?.phase as
          | string
          | undefined;
        const summary =
          ((out as { name?: unknown } | null)?.name as string | undefined) ??
          ((out as { reason?: unknown } | null)?.reason as string | undefined);
        entries.push({
          ts: row.calledAt,
          kind,
          phase,
          durationMs: row.durationMs,
          success: row.success,
          summary,
        });
      }
    }
    entries.sort((a, b) => b.ts - a.ts);
    return { skillId: id, entries };
  });

  /**
   * `GET /api/v1/skills/:id/usage` — cross-link payload for the skill
   * drawer. Resolves `sourcePolicyIds` / `sourceWorldModelIds` to
   * {id, name/title} pairs so the viewer can render clickable chips
   * without fetching every policy / world-model.
   */
  routes.setPattern("GET /api/v1/skills/:id/usage", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const skill = await deps.core.getSkill(id as SkillId);
    if (!skill) {
      writeError(ctx, 404, "not_found", `skill not found: ${id}`);
      return;
    }
    const sourcePolicies = await Promise.all(
      skill.sourcePolicyIds.map(async (pid) => {
        const p = await deps.core.getPolicy(pid);
        return p
          ? { id: p.id, title: p.title, status: p.status, gain: p.gain }
          : { id: pid, title: null, status: null, gain: null };
      }),
    );
    const sourceWorldModels = await Promise.all(
      skill.sourceWorldModelIds.map(async (wid) => {
        const w = await deps.core.getWorldModel(wid);
        return w ? { id: w.id, title: w.title } : { id: wid, title: null };
      }),
    );
    return {
      sourcePolicies,
      sourceWorldModels,
    };
  });

  routes.set("POST /api/v1/skills/archive", async (ctx) => {
    // Accept either `{id}` (JSON-RPC flavour) or `{skillId}` (viewer).
    const body = parseJson<{ id?: string; skillId?: string; reason?: string }>(ctx);
    const target = body.id ?? body.skillId;
    if (!target) {
      writeError(ctx, 400, "invalid_argument", "id or skillId is required");
      return;
    }
    await deps.core.archiveSkill(target as SkillId, body.reason);
    return { ok: true };
  });

  /**
   * `POST /api/v1/skills/reactivate` — flip an archived skill back to
   * active. Accepts either `{id}` or `{skillId}` to match the viewer's
   * existing archive flow.
   */
  routes.set("POST /api/v1/skills/reactivate", async (ctx) => {
    const body = parseJson<{ id?: string; skillId?: string }>(ctx);
    const target = body.id ?? body.skillId;
    if (!target) {
      writeError(ctx, 400, "invalid_argument", "id or skillId is required");
      return;
    }
    const updated = await deps.core.reactivateSkill(target as SkillId);
    if (!updated) {
      writeError(ctx, 404, "not_found", `skill not found: ${target}`);
      return;
    }
    return updated;
  });

  /**
   * `DELETE /api/v1/skills/:id` — hard delete (vs `archive` which just
   * flips status). Idempotent.
   */
  routes.setPattern("DELETE /api/v1/skills/:id", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    return await deps.core.deleteSkill(id as SkillId);
  });

  /**
   * `PATCH /api/v1/skills/:id` — viewer's edit modal. Mutable fields:
   * `name`, `invocationGuide`. Returns the updated DTO.
   */
  routes.setPattern("PATCH /api/v1/skills/:id", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const body = parseJson<{ name?: string; invocationGuide?: string }>(ctx);
    const updated = await deps.core.updateSkill(id as SkillId, {
      name: typeof body.name === "string" ? body.name : undefined,
      invocationGuide:
        typeof body.invocationGuide === "string" ? body.invocationGuide : undefined,
    });
    if (!updated) {
      writeError(ctx, 404, "not_found", `skill not found: ${id}`);
      return;
    }
    return updated;
  });

  /**
   * `POST /api/v1/skills/:id/share` — set/clear the share state.
   * Same body shape as `POST /api/v1/traces/:id/share`.
   */
  routes.setPattern("POST /api/v1/skills/:id/share", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const body = parseJson<{
      scope?: "private" | "public" | "hub" | null;
      target?: string | null;
    }>(ctx);
    const scope = body.scope === undefined ? "public" : body.scope;
    const updated = await deps.core.shareSkill(id as SkillId, {
      scope: scope ?? null,
      target: body.target ?? null,
      sharedAt: scope ? Date.now() : null,
    });
    if (!updated) {
      writeError(ctx, 404, "not_found", `skill not found: ${id}`);
      return;
    }
    return updated;
  });

  /**
   * `GET /api/v1/skills/:id/download` — bundle the skill's invocation
   * guide as a one-file ZIP (`SKILL.md`). Lets the user grab the
   * guide for sharing into another agent or backing up locally.
   */
  routes.setPattern("GET /api/v1/skills/:id/download", async (ctx) => {
    const id = ctx.params.id;
    if (!id) {
      writeError(ctx, 400, "invalid_argument", "id is required");
      return;
    }
    const skill = await deps.core.getSkill(id as SkillId);
    if (!skill) {
      writeError(ctx, 404, "not_found", `skill not found: ${id}`);
      return;
    }
    const md = renderSkillMarkdown(skill);
    const packageName = sanitizeFilename(skill.name || skill.id);
    const buf = buildSingleFileZip(`${packageName}/SKILL.md`, md);
    const filename = packageName + ".zip";
    ctx.res.writeHead(200, {
      "content-type": "application/zip",
      "content-length": String(buf.length),
      "content-disposition": `attachment; filename="${filename}"`,
    });
    ctx.res.end(buf);
  });
}

function renderSkillMarkdown(skill: SkillDTO): string {
  const name = skill.name || skill.id;
  const description = skillDescription(skill.invocationGuide, name);
  const frontmatter =
    `---\n` +
    `name: ${yamlString(name)}\n` +
    `description: ${yamlString(description)}\n` +
    `---\n\n`;
  const head = `# ${name}\n\n`;
  const meta = `> id: ${skill.id}\n> status: ${skill.status}\n> version: ${skill.version}\n\n`;
  const guide = skill.invocationGuide?.trim() || "(no invocation guide)";
  return frontmatter + head + meta + guide + "\n";
}

function skillDescription(invocationGuide: string | undefined, name: string): string {
  const fallback = `Use ${name} when this learned skill matches the current task.`;
  const lines = (invocationGuide ?? "")
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter(Boolean);
  const firstBodyLine =
    lines.find((line) => !line.startsWith("#") && !line.startsWith(">")) ?? fallback;
  return firstBodyLine.replace(/\s+/g, " ").slice(0, 240);
}

function yamlString(raw: string): string {
  return JSON.stringify(raw.replace(/\s+/g, " ").trim());
}

function sanitizeFilename(raw: string): string {
  const trimmed = raw.trim();
  const safe = trimmed.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
  return safe.length > 0 ? safe.slice(0, 80) : "skill";
}

function limitOrUndefined(raw: string | null): number | undefined {
  if (raw === null) return undefined;
  const n = Number(raw);
  return Number.isFinite(n) && n > 0 ? Math.min(n, 500) : undefined;
}

function safeJson(text: string): unknown {
  if (!text) return null;
  try {
    return JSON.parse(text);
  } catch {
    return null;
  }
}
