import crypto from "node:crypto";
import { getSiteSetting } from "@/lib/site-settings";
import { getPublicUrl } from "@/lib/storage";

const LABEL_NAMES: Record<string, string[]> = {
  bug: ["bug"],
  suggestion: ["enhancement"],
  question: ["question"],
};

/** Gitea's issue-create endpoint takes label IDs, not names — resolve by
 *  fetching the repo's existing labels and matching case-insensitively.
 *  A repo with no matching labels (or a lookup failure) just means the
 *  issue is created unlabeled, not that creation fails outright. */
async function resolveLabelIds(baseUrl: string, token: string, repo: string, names: string[]): Promise<number[]> {
  if (names.length === 0) return [];
  try {
    const res = await fetch(`${baseUrl}/api/v1/repos/${repo}/labels`, {
      headers: { Authorization: `token ${token}` },
      signal: AbortSignal.timeout(10000),
    });
    if (!res.ok) return [];
    const labels = (await res.json()) as { id: number; name: string }[];
    const wanted = new Set(names.map((n) => n.toLowerCase()));
    return labels.filter((l) => wanted.has(l.name.toLowerCase())).map((l) => l.id);
  } catch {
    return [];
  }
}

/** Shared by ticket creation and the admin retry action, so a retry embeds
 *  attachments exactly the same way the original attempt would have. Image
 *  attachments embed inline (`![](url)`) so they render as a preview in the
 *  Gitea issue instead of just a clickable link — everything else (PDF,
 *  text, zip) stays a plain link since Gitea can't preview those anyway. */
export function buildGiteaIssueBody(opts: {
  description: string;
  userId: string;
  attachments: { key: string; contentType: string }[];
}): string {
  const attachmentLines = opts.attachments.map((a) => {
    const url = getPublicUrl(a.key);
    return a.contentType.startsWith("image/") ? `![attachment](${url})` : `- ${url}`;
  });
  return [
    opts.description,
    attachmentLines.length > 0 ? `\n**Attachments:**\n${attachmentLines.join("\n")}` : "",
    `\n---\nReported via Epicure support form by user \`${opts.userId}\`.`,
  ].join("\n");
}

/**
 * Opens an issue on the configured Gitea repo for a support ticket. Returns
 * the issue's HTML URL on success, or null if Gitea isn't configured or the
 * request fails — callers must treat this as best-effort, not block on it,
 * since the ticket itself is already saved regardless.
 */
export async function createGiteaIssue(opts: {
  type: string;
  title: string;
  body: string;
}): Promise<{ url: string | null; number: number | null; error: string | null }> {
  const [rawBaseUrl, token, repo] = await Promise.all([
    getSiteSetting("GITEA_URL"),
    getSiteSetting("GITEA_TOKEN"),
    getSiteSetting("GITEA_REPO"),
  ]);

  if (!rawBaseUrl || !token || !repo) {
    return { url: null, number: null, error: null };
  }

  const baseUrl = rawBaseUrl.replace(/\/$/, "");

  try {
    const labelIds = await resolveLabelIds(baseUrl, token, repo, LABEL_NAMES[opts.type] ?? []);

    const res = await fetch(`${baseUrl}/api/v1/repos/${repo}/issues`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `token ${token}`,
      },
      body: JSON.stringify({
        title: opts.title,
        body: opts.body,
        labels: labelIds,
      }),
      signal: AbortSignal.timeout(10000),
    });

    if (!res.ok) {
      const text = await res.text().catch(() => "");
      return { url: null, number: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
    }

    const issue = (await res.json()) as { html_url?: string; number?: number };
    return {
      url: issue.html_url ?? null,
      number: issue.number ?? null,
      error: issue.html_url ? null : "Gitea response had no html_url",
    };
  } catch (err) {
    // A thrown fetch error (DNS failure, connection refused, timeout, TLS
    // error) never reaches the res.ok branch above, so its real cause would
    // otherwise be lost — err.message alone is often just "fetch failed"
    // with the actual reason nested in err.cause. Log server-side with the
    // full cause chain, and fold a short form into the returned message so
    // it also shows up in the admin support view's tooltip, not just logs.
    const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[gitea] createGiteaIssue failed", { baseUrl, repo, message, cause });
    return { url: null, number: null, error: cause ? `${message}: ${cause}` : message };
  }
}

async function giteaCreds(): Promise<{ baseUrl: string; token: string; repo: string } | null> {
  const [rawBaseUrl, token, repo] = await Promise.all([
    getSiteSetting("GITEA_URL"),
    getSiteSetting("GITEA_TOKEN"),
    getSiteSetting("GITEA_REPO"),
  ]);
  if (!rawBaseUrl || !token || !repo) return null;
  return { baseUrl: rawBaseUrl.replace(/\/$/, ""), token, repo };
}

/** Posts a comment on the linked Gitea issue. Best-effort — callers must not
 * block ticket/comment saving on this succeeding. */
export async function postGiteaComment(
  issueNumber: number,
  body: string
): Promise<{ id: number | null; error: string | null }> {
  const creds = await giteaCreds();
  if (!creds) return { id: null, error: null };

  try {
    const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}/comments`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
      body: JSON.stringify({ body }),
      signal: AbortSignal.timeout(10000),
    });
    if (!res.ok) {
      const text = await res.text().catch(() => "");
      return { id: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
    }
    const comment = (await res.json()) as { id?: number };
    return { id: comment.id ?? null, error: null };
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[gitea] postGiteaComment failed", { issueNumber, message });
    return { id: null, error: message };
  }
}

/** Opens or closes the linked Gitea issue to mirror a ticket status change.
 * Best-effort, same reasoning as postGiteaComment. */
export async function setGiteaIssueState(
  issueNumber: number,
  state: "open" | "closed"
): Promise<{ ok: boolean; error: string | null }> {
  const creds = await giteaCreds();
  if (!creds) return { ok: false, error: null };

  try {
    const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
      body: JSON.stringify({ state }),
      signal: AbortSignal.timeout(10000),
    });
    if (!res.ok) {
      const text = await res.text().catch(() => "");
      return { ok: false, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
    }
    return { ok: true, error: null };
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[gitea] setGiteaIssueState failed", { issueNumber, state, message });
    return { ok: false, error: message };
  }
}

/** Gitea signs webhook deliveries with HMAC-SHA256 hex digest of the raw
 * body (X-Gitea-Signature) — no timestamp prefix, unlike Stripe. */
export function verifyGiteaSignature(rawBody: string, signature: string, secret: string): boolean {
  const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");
  let providedBuf: Buffer;
  try {
    providedBuf = Buffer.from(signature, "hex");
  } catch {
    return false;
  }
  if (providedBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
