import * as vscode from "vscode";
import * as crypto from "node:crypto";

/**
 * Thin client for console2's admin EDM API.
 *
 * ── On authentication ────────────────────────────────────────────────────────
 *
 * These endpoints sit behind withConsoleUserSession(), the same cookie session
 * the browser console uses. The extension therefore reuses that session rather
 * than introducing a second way in.
 *
 * That is a deliberate choice over the alternative, which would be shipping the
 * INTERNAL_SERVER_API_KEY to every developer's laptop. That key is a
 * full-trust, org-wide shared secret — putting it on N machines, in N settings
 * files, with no revocation story, in exchange for skipping one copy/paste, is
 * a bad trade.
 *
 * The cost is real: you paste a cookie and re-paste it when the session
 * expires. The proper fix is per-user personal access tokens on the console2
 * side, which does not exist yet. Until it does, this is the honest option.
 *
 * The cookie is held in VS Code SecretStorage (OS keychain), never in settings
 * or workspace files where it would end up committed.
 */

const COOKIE_KEY = "karmaEdm.sessionCookie";
const APIKEY_KEY = "12345678-abcd-1234-abcd-1234567890ab";

export type EDMTemplateSummary = {
  id: string;
  name: string;
  updated_at: string;
  versions: { active: 0 | 1; subject: string; html_content: string }[];
};

export type UnknownHelper = {
  name: string;
  count: number;
  /** `{{#foo a b}}` can only be a helper call; `{{#foo}}` might be a data field. */
  hasArguments: boolean;
};

export type ImageScanReport = {
  total: number;
  unresolved: { url: string; kind: string }[];
  dataUris: number;
  insecure: { url: string; kind: string }[];
  localhost: { url: string; kind: string }[];
  external: string[];
  ok: boolean;
  helpers?: UnknownHelper[];
};

export type AssetEntry = { original: string; path: string; url: string; sha256: string };

export type EDMFolder = {
  id: string;
  parent_id: string | null;
  name: string;
  sort_order: number;
};

export type EDMFolderTemplate = {
  id: string;
  name: string;
  source: string;
  folder_id: string | null;
  updated_at: string;
  active_version_id: string | null;
  subject: string | null;
};

export type EDMFolderAsset = {
  original: string;
  path: string;
  url: string;
  sha256: string;
  usedBy: string[];
};

export type EDMBrowseResult = {
  folder: EDMFolder | null;
  breadcrumb: { id: string; name: string }[];
  folders: EDMFolder[];
  templates: EDMFolderTemplate[];
  assets: EDMFolderAsset[];
};

/**
 * Approval states an EDM moves through, in order.
 *
 * Mirrors apps/core/src/internal/edm/edm-workflow-states.ts. Duplicated rather
 * than imported: this extension is deliberately outside the pnpm workspace, so
 * it has no path to the core package. If that file changes, change this one.
 */
export const EDM_WORKFLOW_STATES = [
  "DEV_IN_PROGRESS",
  "DEV_COMPLETED",
  "READY_FOR_TEST",
  "TESTING",
  "TESTED",
  "IN_REVIEW",
  "PUBLISHED",
  "CHANGE_REQUESTED",
] as const;

export type EDMWorkflowState = (typeof EDM_WORKFLOW_STATES)[number];

export const EDM_WORKFLOW_LABELS: Record<string, string> = {
  DEV_IN_PROGRESS: "Development in progress",
  DEV_COMPLETED: "Development completed",
  READY_FOR_TEST: "Ready for test",
  TESTING: "Testing",
  TESTED: "Tested",
  IN_REVIEW: "In review",
  PUBLISHED: "Published",
  CHANGE_REQUESTED: "Change requested",
};

export type WorkflowChangeRequest = {
  id: string;
  task_id: string;
  description: string;
  status: "PENDING" | "RESOLVED";
  created_at: string;
  target_ref: string | null;
  target_label: string | null;
  line_number: number | null;
  requester_first_name: string | null;
  assignee_first_name: string | null;
};

export type WorkflowTask = {
  id: string | null;
  entity_type: string;
  entity_id: string;
  status: string;
  assignee_ids?: string[];
  assignees?: { id: string; first_name: string; last_name?: string; email: string }[];
  change_requests: WorkflowChangeRequest[];
};

export class NotAuthenticatedError extends Error { }

export class EDMApi {
  constructor(private readonly secrets: vscode.SecretStorage) { }

  private baseUrl(): string {
    const configured = vscode.workspace
      .getConfiguration("karmaEdm")
      .get<string>("coreUrl", "");
    return (configured || "").replace(/\/+$/, "");
  }

  async setCookie(cookie: string): Promise<void> {
    await this.secrets.store(COOKIE_KEY, cookie.trim());
    await this.secrets.delete(APIKEY_KEY);
  }

  async setApiKey(key: string): Promise<void> {
    await this.secrets.store(APIKEY_KEY, key.trim());
    await this.secrets.delete(COOKIE_KEY);
  }

  async clearCookie(): Promise<void> {
    await this.secrets.delete(COOKIE_KEY);
    await this.secrets.delete(APIKEY_KEY);
  }

  async hasCookie(): Promise<boolean> {
    return (await this.authMode()) !== null;
  }

  /** Which credential is stored, if either. */
  async authMode(): Promise<"apiKey" | "cookie" | null> {
    if (await this.secrets.get(APIKEY_KEY)) return "apiKey";
    if (await this.secrets.get(COOKIE_KEY)) return "cookie";
    return null;
  }

  /**
   * Mint the AES-256-GCM payload console2's apiKeyAuth expects.
   *
   * Format is exactly what that middleware parses: "<iv hex>:<ciphertext+tag hex>",
   * with the auth tag appended to the ciphertext rather than sent separately.
   * The embedded timestamp is valid for 60 seconds, so this is minted per
   * request and never cached.
   */
  private buildApiPayload(secret: string): string {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv(
      "aes-256-gcm",
      Buffer.from(secret, "utf-8"),
      iv,
    );
    const encrypted = Buffer.concat([
      cipher.update(JSON.stringify({ key: secret, ts: Date.now() }), "utf-8"),
      cipher.final(),
    ]);
    const payload = Buffer.concat([encrypted, cipher.getAuthTag()]);
    return `${iv.toString("hex")}:${payload.toString("hex")}`;
  }

  private async headers(json = true): Promise<Record<string, string>> {
    const h: Record<string, string> = { Accept: "application/json" };

    const apiKey = await this.secrets.get(APIKEY_KEY);
    if (apiKey) {
      h["x-api-payload"] = this.buildApiPayload(apiKey);
    } else {
      const cookie = await this.secrets.get(COOKIE_KEY);
      if (!cookie) {
        throw new NotAuthenticatedError(
          "No credential stored. Run “Karma EDM: Sign in”.",
        );
      }
      h.Cookie = cookie;
    }

    if (json) h["Content-Type"] = "application/json";
    return h;
  }

  /**
   * The two credentials reach different route groups: the API key authenticates
   * /v1/internal/edm (apiKeyAuth), a console cookie authenticates
   * /v1/admin-console/edm (withConsoleUserSession). Same handlers behind both,
   * so only the prefix and a few paths differ.
   */
  private async basePath(): Promise<string> {
    return (await this.authMode()) === "apiKey"
      ? "/v1/internal/edm"
      : "/v1/admin-console/edm";
  }

  private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
    const base = this.baseUrl();
    if (!base) {
      throw new Error("karmaEdm.coreUrl is not set. Configure it in settings.");
    }

    const res = await fetch(`${base}${await this.basePath()}${path}`, init);

    if (res.status === 401 || res.status === 403) {
      throw new NotAuthenticatedError(
        (await this.authMode()) === "apiKey"
          ? "Console rejected the API key. Check it matches INTERNAL_SERVER_API_KEY on the server."
          : "Console rejected the stored session — it has probably expired. Sign in again.",
      );
    }

    const body = (await res.json().catch(() => null)) as
      | { success?: boolean; message?: string; data?: T; errors?: string[] }
      | null;

    if (!res.ok || !body?.success) {
      const detail = body?.errors?.length ? `\n- ${body.errors.join("\n- ")}` : "";
      throw new Error(`${body?.message ?? `HTTP ${res.status}`}${detail}`);
    }

    return body.data as T;
  }

  // ── workflow ──────────────────────────────────────────────────────────────

  /**
   * Workflow endpoints live outside the EDM route group and have no API-key
   * variant — they are cookie-session only, because every action they take is
   * attributed to a console user. An API-key session has no user to attribute
   * to, so the honest answer is that this half of the extension needs a cookie.
   */
  private async workflowRequest<T>(
    path: string,
    init: RequestInit = {},
  ): Promise<T> {
    const base = this.baseUrl();
    if (!base) {
      throw new Error("karmaEdm.coreUrl is not set. Configure it in settings.");
    }
    if ((await this.authMode()) === "apiKey") {
      throw new NotAuthenticatedError(
        "Approval actions are attributed to a console user, so they need a session cookie rather than the API key. " +
          "Run “Karma EDM: Sign in” and choose the session cookie.",
      );
    }

    const res = await fetch(`${base}/v1/admin-console/workflows${path}`, init);
    if (res.status === 401 || res.status === 403) {
      throw new NotAuthenticatedError(
        "Console rejected the stored session — it has probably expired. Sign in again.",
      );
    }

    const body = (await res.json().catch(() => null)) as
      | { success?: boolean; message?: string; data?: T }
      | null;
    if (!res.ok || !body?.success) {
      throw new Error(body?.message ?? `HTTP ${res.status}`);
    }
    return body.data as T;
  }

  /** Approval state of one template. Never null: an untracked one reads as its first state. */
  async getWorkflowTask(templateId: string): Promise<WorkflowTask> {
    return this.workflowRequest<WorkflowTask>(
      `/tasks/entity/EDM_TEMPLATE/${encodeURIComponent(templateId)}`,
      { method: "GET", headers: await this.headers(false) },
    );
  }

  async setWorkflowStatus(
    templateId: string,
    status: EDMWorkflowState,
  ): Promise<WorkflowTask> {
    return this.workflowRequest<WorkflowTask>("/tasks", {
      method: "POST",
      headers: await this.headers(),
      body: JSON.stringify({
        entity_type: "EDM_TEMPLATE",
        entity_id: templateId,
        status,
      }),
    });
  }

  /** Open change requests pointing at any of these templates. */
  async listOpenChangeRequests(
    templateIds: string[],
  ): Promise<WorkflowChangeRequest[]> {
    if (templateIds.length === 0) return [];
    return this.workflowRequest<WorkflowChangeRequest[]>(
      `/change-requests?targets=${encodeURIComponent(templateIds.join(","))}`,
      { method: "GET", headers: await this.headers(false) },
    );
  }

  async createChangeRequest(
    taskId: string,
    input: {
      description: string;
      targetRef?: string | null;
      targetLabel?: string | null;
      lineNumber?: number | null;
    },
  ): Promise<WorkflowChangeRequest> {
    return this.workflowRequest<WorkflowChangeRequest>(
      `/tasks/${encodeURIComponent(taskId)}/change-requests`,
      {
        method: "POST",
        headers: await this.headers(),
        body: JSON.stringify({
          description: input.description,
          target_ref: input.targetRef ?? null,
          target_label: input.targetLabel ?? null,
          line_number: input.targetRef ? (input.lineNumber ?? null) : null,
        }),
      },
    );
  }

  async resolveChangeRequest(id: string): Promise<unknown> {
    return this.workflowRequest(
      `/change-requests/${encodeURIComponent(id)}/resolve`,
      { method: "PUT", headers: await this.headers() },
    );
  }

  async scanImages(html: string): Promise<ImageScanReport> {
    const path = (await this.authMode()) === "apiKey" ? "/scan" : "/scan-images";
    return this.request<ImageScanReport>(path, {
      method: "POST",
      headers: await this.headers(),
      body: JSON.stringify({ html }),
    });
  }

  /** One folder's own contents. null = the root. */
  async browse(folderId: string | null): Promise<EDMBrowseResult> {
    const qs = folderId ? `?folder_id=${encodeURIComponent(folderId)}` : "";
    return this.request<EDMBrowseResult>(`/browse${qs}`, {
      method: "GET",
      headers: await this.headers(false),
    });
  }

  /**
   * Fetch an image's bytes from its public URL.
   *
   * Deliberately not through `request()`: assets live in a public bucket, not
   * behind the console session, and the response is binary rather than the
   * JSON envelope every other endpoint returns.
   */
  async downloadAsset(url: string): Promise<Uint8Array> {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Could not download ${url} (HTTP ${res.status})`);
    return new Uint8Array(await res.arrayBuffer());
  }

  async listTemplates(): Promise<EDMTemplateSummary[]> {
    const path = (await this.authMode()) === "apiKey" ? "/templates" : "";
    return this.request<EDMTemplateSummary[]>(path, {
      method: "GET",
      headers: await this.headers(false),
    });
  }

  async getTemplate(templateId: string): Promise<EDMTemplateSummary> {
    // The internal group reserves /templates/:id for the worker's renderable
    // payload, so the full record with html lives at /full.
    const path =
      (await this.authMode()) === "apiKey"
        ? `/templates/${encodeURIComponent(templateId)}/full`
        : `/${encodeURIComponent(templateId)}`;
    return this.request<EDMTemplateSummary>(path, {
      method: "GET",
      headers: await this.headers(false),
    });
  }

  /** Upload one image and get back its permanent public URL. */
  async uploadAsset(
    filename: string,
    contentType: string,
    bytes: Uint8Array,
  ): Promise<AssetEntry> {
    const form = new FormData();
    // Copy into a fresh ArrayBuffer: a Uint8Array read from disk may be a view
    // over a larger pooled buffer, and Blob would otherwise capture the slack.
    const copy = new Uint8Array(bytes.byteLength);
    copy.set(bytes);
    form.append("image", new Blob([copy], { type: contentType }), filename);

    // No Content-Type header — fetch must set it so the multipart boundary
    // matches the body it generated.
    return this.request<AssetEntry>("/assets", {
      method: "POST",
      headers: await this.headers(false),
      body: form,
    });
  }

  async createTemplate(input: {
    name: string;
    subject: string;
    html_content: string;
    folder_id?: string | null;
  }): Promise<EDMTemplateSummary> {
    const path = (await this.authMode()) === "apiKey" ? "/templates" : "";
    return this.request<EDMTemplateSummary>(path, {
      method: "POST",
      headers: await this.headers(),
      body: JSON.stringify({ ...input, active: 1 }),
    });
  }

  async updateTemplate(
    templateId: string,
    input: { name?: string; subject?: string; html_content?: string },
  ): Promise<EDMTemplateSummary> {
    const path =
      (await this.authMode()) === "apiKey"
        ? `/templates/${encodeURIComponent(templateId)}`
        : `/${encodeURIComponent(templateId)}`;
    return this.request<EDMTemplateSummary>(path, {
      method: "PATCH",
      headers: await this.headers(),
      body: JSON.stringify(input),
    });
  }
}
