import "server-only";

/**
 * KolayMarketing Partner API client. Copied into each partner panel from kolaymarketing-2026
 * `docs/partner-client/kolaymarketing.ts`; change it there, not in the copy. Contract: `docs/partner-api.md`.
 *
 * v1 surface: workspace, intake, ideas. v2 surface (ADR-0010): capabilities, content, calendar, published posts,
 * media, and — on behalf of a named member (`actorEmail`) — open, write, approve, reject, schedule, unschedule.
 * KolayMarketing publishes. Nothing here can publish, publish now, link a channel or open a publish gate: the API
 * has no such endpoint. A slot is a request, re-checked against both approvals when it comes due.
 *
 * One bearer key, scoped to one workspace, kept on the server. It never reaches the browser, a log line or an error
 * message, and neither does a submitted value.
 */

export type IntakeKind = "text" | "longtext" | "url" | "choice" | "secret";
export type IntakeStatus = "missing" | "provided" | "accepted" | "rejected";
export type Network = "linkedin" | "instagram" | "facebook" | "tiktok" | "x" | "youtube";

export type IntakeField = { name: string; label: string; secret: boolean; required: boolean };

export type IntakeItem = {
  key: string;
  group: string;
  label: string;
  help: string | null;
  kind: IntakeKind;
  required: boolean;
  options?: { value: string; label: string }[];
  fields?: IntakeField[];
  status: IntakeStatus;
  /** Always null for a secret item: the API never sends a secret back. */
  value: string | null;
  reviewNote: string | null;
  submittedAt: string | null;
  submittedBy: string | null;
  connected?: boolean;
};

export type PartnerWorkspace = {
  workspace: { id: string; name: string; siteDomain: string | null; timeZone: string };
  publishing: { tenantOpen: boolean; workspaceOpen: boolean };
  channels: { network: Network; name: string; status: "active" | "disabled" | "broken" }[];
  content: { status: string; label: string; count: number }[];
  recentPosts: { network: Network; channelName: string; topic: string; publishedAt: string; postUrl: string | null }[];
  intake: { total: number; missingRequired: number; provided: number; accepted: number; rejected: number };
};

export type IntakeSubmission = { value: string } | { secret: Record<string, string> };

/** Lands in Kolay Marketing as an `idea` item; nothing is written or published by it. */
export type IdeaSubmission = {
  /** ≤ 200 characters. */
  topic: string;
  /** Background for the writer: facts, links, angle. Never a finished post. ≤ 4000 characters. */
  brief: string;
  /** Where it came from, e.g. "vapartners:admin"; shown to the marketing team. */
  source: string;
  submittedBy: string;
};

export type PartnerIdea = { id: string; status: string; topic: string; createdAt: string };

export type ContentStatus =
  | "idea"
  | "text_draft"
  | "text_review"
  | "assets_pending"
  | "asset_review"
  | "scheduled"
  | "queued"
  | "published"
  | "measured"
  | "failed"
  | "rejected"
  | "archived";
export type ReviewStage = "text" | "media";
export type RejectReason = "brand_voice" | "wrong_info" | "image_quality" | "banned_word" | "timing" | "other";

/** An unmeasured number is `value: null` with the reason in `source` — never show it as 0. */
export type Metric = { key: string; value: number | null; source: string; measuredAt: string | null };

/** Ask this before opening a tab: a key without a scope gets 403 on that tab's endpoints. */
export type PartnerCapabilities = {
  apiVersion: "2";
  scopes: string[];
  features: Record<string, boolean>;
  /** What this key may call, e.g. "GET /v1/partner/content". */
  endpoints: string[];
};

export type PartnerContentItem = {
  id: string;
  status: ContentStatus;
  statusLabel: string;
  topic: string;
  hook: string | null;
  body: string | null;
  variants: { channelId: string; network: Network; body: string; hashtags: string[] }[];
  /** Full media, with links, comes from `getMedia`. */
  mediaCount: number;
  textApproved: boolean;
  mediaApproved: boolean;
  rejectReason: RejectReason | null;
  rejectNote: string | null;
  slotAt: string | null;
  /** Why this item cannot go out yet, in plain Turkish; null means nothing stands in the way. */
  publishBlocker: string | null;
  postUrl: string | null;
  updatedAt: string;
};

export type PartnerContentPage = { items: PartnerContentItem[]; nextCursor: string | null };

export type PartnerCalendar = {
  timeZone: string;
  entries: { id: string; status: ContentStatus; topic: string; slotAt: string; channels: { network: Network; name: string }[] }[];
};

export type PartnerPublishedPage = {
  items: {
    contentId: string;
    network: Network;
    channelName: string;
    topic: string;
    publishedAt: string;
    postUrl: string | null;
    clicks: Metric;
    visits: Metric;
  }[];
  nextCursor: string | null;
};

export type PartnerMedia = {
  assets: {
    id: string;
    kind: "image" | "video" | "audio";
    mime: string | null;
    width: number | null;
    height: number | null;
    durationMs: number | null;
    /** Short-lived; ask again rather than storing it. Null when no link can be issued. */
    url: string | null;
  }[];
  urlExpiresInSeconds: number;
};

/** There is no `formats` field: the API refuses it with 400, so this type has none. */
export type ContentRequest = {
  /** ≤ 200 characters. */
  topic: string;
  /** Stored with the item for the team; the text is written from the workspace brief. ≤ 4000 characters. */
  brief: string;
  /** http(s) link the idea came from, e.g. the landing page. */
  sourceUrl?: string;
  channelIds?: string[];
  /** The member it is opened for; without it the tenant owner is the nominal author. */
  actorEmail?: string;
};

export class KolayMarketingError extends Error {
  readonly code: string;
  readonly httpStatus: number;

  constructor(code: string, message: string, httpStatus: number) {
    super(message);
    this.name = "KolayMarketingError";
    this.code = code;
    this.httpStatus = httpStatus;
  }
}

export const DEFAULT_API_URL = "https://api.kolaymarketing.com/v1";

function config() {
  const key = process.env.KOLAYMARKETING_API_KEY?.trim();
  if (!key) return null;
  return { key, base: (process.env.KOLAYMARKETING_API_URL?.trim() || DEFAULT_API_URL).replace(/\/+$/, "") };
}

export const kolayMarketingConfigured = () => config() !== null;

/** 8-128 characters of letters, digits, `_` and `-`. One per operation; reuse it only to retry that operation. */
const IDEMPOTENCY_KEY = /^[A-Za-z0-9_-]{8,128}$/;

type CallOptions = { idempotencyKey?: string; timeoutMs?: number };

async function call<T>(op: string, method: "GET" | "PUT" | "POST", path: string, body?: unknown, opts: CallOptions = {}): Promise<T> {
  const cfg = config();
  if (!cfg) throw new KolayMarketingError("not_configured", "KOLAYMARKETING_API_KEY is not set.", 0);
  if (opts.idempotencyKey !== undefined && !IDEMPOTENCY_KEY.test(opts.idempotencyKey)) {
    throw new KolayMarketingError("idempotency_key_required", "Idempotency key must be 8-128 letters, digits, _ or -.", 0);
  }

  // `cache` is a browser/Next fetch option that Node's own RequestInit type does not list.
  const init: RequestInit & { cache?: "no-store" } = {
    method,
    headers: {
      Authorization: `Bearer ${cfg.key}`,
      Accept: "application/json",
      ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
      ...(opts.idempotencyKey !== undefined ? { "Idempotency-Key": opts.idempotencyKey } : {}),
    },
    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
    cache: "no-store",
    signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
  };

  let res: Response;
  try {
    res = await fetch(`${cfg.base}${path}`, init);
  } catch {
    console.error(`kolaymarketing unreachable: ${op}`);
    throw new KolayMarketingError("upstream_error", "Kolay Marketing did not answer.", 0);
  }

  const parsed: unknown = await res.json().catch(() => undefined);
  if (parsed === undefined) {
    console.error(`kolaymarketing non-json: ${op} ${res.status}`);
    throw new KolayMarketingError("internal_error", `Unexpected answer from Kolay Marketing (HTTP ${res.status}).`, res.status);
  }
  if (res.ok) return parsed as T;

  const error = (parsed as { error?: { code?: unknown; message?: unknown } } | null)?.error;
  const code = typeof error?.code === "string" ? error.code : "internal_error";
  const message = typeof error?.message === "string" ? error.message : `HTTP ${res.status}`;
  console.error(`kolaymarketing error: ${op} ${res.status} ${code}`);
  throw new KolayMarketingError(code, message, res.status);
}

/** This workspace: connected accounts, content per status, last posts, intake totals. */
export const getWorkspace = () => call<PartnerWorkspace>("workspace", "GET", "/partner/workspace");

/** Everything the marketing team needs from this project, with what was already handed over (secrets never echoed). */
export const getIntake = async () => (await call<{ items: IntakeItem[] }>("intake", "GET", "/partner/intake")).items;

/** Hands one item over. `submittedBy` is the signed-in admin's email. */
export const submitIntake = (key: string, submission: IntakeSubmission, submittedBy: string) =>
  call<IntakeItem>(`intake.${key}`, "PUT", `/partner/intake/${encodeURIComponent(key)}`, { ...submission, submittedBy });

/** One idea; Kolay Marketing opens an `idea` item that its team then writes, approves and publishes. */
export const submitIdea = (idea: IdeaSubmission) => call<PartnerIdea>("ideas.create", "POST", "/partner/ideas", idea);

// ── v2 ────────────────────────────────────────────────────────────────────────────────────────────────────

function query(params: Record<string, string | undefined>): string {
  const pairs = Object.entries(params).filter((pair): pair is [string, string] => pair[1] !== undefined && pair[1] !== "");
  return pairs.length ? `?${new URLSearchParams(pairs).toString()}` : "";
}

const contentPath = (id: string, action?: string) => `/partner/content/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;

/** Scopes, tenant features and the endpoints this key may call. Needs no scope. */
export const getCapabilities = () => call<PartnerCapabilities>("capabilities", "GET", "/partner/capabilities");

/** Content, most recently changed first, 50 a page; pass `nextCursor` back as `cursor`. Scope `content_read`. */
export const listContent = (filter: { status?: ContentStatus; from?: string; to?: string; cursor?: string } = {}) =>
  call<PartnerContentPage>("content.list", "GET", `/partner/content${query(filter)}`);

/** One item, with `publishBlocker`. 404 also when it belongs to another workspace. Scope `content_read`. */
export const getContent = (id: string) => call<PartnerContentItem>("content.get", "GET", contentPath(id));

/** Scheduled items between two ISO dates, at most 62 days apart. Scope `content_read`. */
export const getCalendar = (from: string, to: string) => call<PartnerCalendar>("calendar", "GET", `/partner/calendar${query({ from, to })}`);

/** Published posts with their measured (or unmeasured) clicks and visits. Scope `content_read`. */
export const listPublished = (filter: { from?: string; to?: string; cursor?: string } = {}) =>
  call<PartnerPublishedPage>("published", "GET", `/partner/published${query(filter)}`);

/** Media of one item with 15-minute links. Scope `content_read`. */
export const getMedia = (contentId: string) => call<PartnerMedia>("media", "GET", `/partner/media${query({ contentId })}`);

/** Opens an `idea` item; counts against the monthly post quota (402 when spent). Scope `content_write`. */
export const createContent = (request: ContentRequest, idempotencyKey: string) =>
  call<PartnerContentItem>(
    "content.create",
    "POST",
    "/partner/content",
    {
      topic: request.topic,
      brief: request.brief,
      ...(request.sourceUrl !== undefined ? { sourceUrl: request.sourceUrl } : {}),
      ...(request.channelIds !== undefined ? { channelIds: request.channelIds } : {}),
      ...(request.actorEmail !== undefined ? { actorEmail: request.actorEmail } : {}),
    },
    { idempotencyKey },
  );

/** Has the model write the text: one to two minutes, five a minute per key. Scope `content_write`. */
export const writeContent = (id: string, idempotencyKey: string, actorEmail?: string) =>
  call<PartnerContentItem>("content.write", "POST", contentPath(id, "write"), actorEmail !== undefined ? { actorEmail } : {}, {
    idempotencyKey,
    timeoutMs: 150_000,
  });

/** Approves the text or the media for a member who is owner, admin or reviewer. Scope `content_review`. */
export const approveContent = (id: string, stage: ReviewStage, actorEmail: string, idempotencyKey: string) =>
  call<PartnerContentItem>("content.approve", "POST", contentPath(id, "approve"), { stage, actorEmail }, { idempotencyKey });

/** Sends the text or the media back, with a reason. Scope `content_review`. */
export const rejectContent = (
  id: string,
  rejection: { stage: ReviewStage; reason: RejectReason; note?: string },
  actorEmail: string,
  idempotencyKey: string,
) =>
  call<PartnerContentItem>(
    "content.reject",
    "POST",
    contentPath(id, "reject"),
    { stage: rejection.stage, reason: rejection.reason, ...(rejection.note !== undefined ? { note: rejection.note } : {}), actorEmail },
    { idempotencyKey },
  );

/** Asks for a slot (sent as UTC). Publishing still waits for both approvals and the open gates. Scope `schedule`. */
export const scheduleContent = (id: string, slotAt: Date | string, actorEmail: string, idempotencyKey: string) =>
  call<PartnerContentItem>(
    "content.schedule",
    "POST",
    contentPath(id, "schedule"),
    { slotAt: new Date(slotAt).toISOString(), actorEmail },
    { idempotencyKey },
  );

/** Removes the slot. Scope `schedule`. */
export const unscheduleContent = (id: string, actorEmail: string, idempotencyKey: string) =>
  call<PartnerContentItem>("content.unschedule", "POST", contentPath(id, "unschedule"), { actorEmail }, { idempotencyKey });
