import axios from "axios";
import { decrypt } from "../lib/encryption";
import type { User } from "@prisma/client";

export function giteaApi(user: User) {
  const pat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
  return axios.create({
    baseURL: `${user.giteaInstanceUrl}/api/v1`,
    headers: {
      Authorization: `token ${pat}`,
      "Content-Type": "application/json",
    },
    timeout: 15000,
  });
}

export async function validateGiteaUrl(url: string): Promise<{ success: boolean; version?: string; error?: string }> {
  try {
    const res = await axios.get(`${url}/api/v1/version`, { timeout: 10000 });
    return { success: true, version: res.data.version };
  } catch (e: any) {
    return { success: false, error: e.message };
  }
}

// Authenticates the PAT and confirms it belongs to the expected account. Gitea 1.19+
// scopes tokens and does not expose granted scopes via the API, so we cannot pre-check
// write:issue non-destructively — but authenticating here catches invalid/expired/wrong
// tokens at setup instead of failing at deploy time.
export async function validateGiteaToken(
  url: string,
  pat: string,
  expectedUsername: string,
): Promise<{ success: boolean; error?: string; login?: string }> {
  try {
    const res = await axios.get(`${url}/api/v1/user`, {
      headers: { Authorization: `token ${pat}` },
      timeout: 10000,
    });
    const login: string | undefined = res.data?.login;
    if (expectedUsername && login && login.toLowerCase() !== expectedUsername.toLowerCase()) {
      return {
        success: false,
        login,
        error: `Token belongs to "${login}", not "${expectedUsername}". Use a token created by ${expectedUsername}.`,
      };
    }
    return { success: true, login };
  } catch (e: any) {
    const status = e?.response?.status;
    if (status === 401) return { success: false, error: "Token is invalid or expired." };
    return { success: false, error: e?.message ?? "Token validation failed" };
  }
}

// Translates opaque Gitea API errors into actionable messages. A 403 on a write almost
// always means the PAT lacks the write scope for that resource.
function giteaWriteError(e: any, action: string): Error {
  if (e?.response?.status === 403) {
    return new Error(
      `Gitea denied ${action} (403 Forbidden). The Personal Access Token is missing write access. ` +
        `Recreate it with Read and Write permission for the "issue" and "repository" scopes.`,
    );
  }
  return e instanceof Error ? e : new Error(String(e));
}

// Lists only the repos the authenticated user owns or has access to (owned +
// collaborator + org member). We deliberately use /user/repos rather than
// /repos/search: for a Gitea site admin, /repos/search returns EVERY repo on the
// instance, which would let admins see and manage repos they have no stake in.
// /user/repos is user-scoped regardless of admin status. Note the shape differs:
// /user/repos returns a bare array, not the /repos/search { ok, data } envelope.
export async function fetchUserRepos(user: User): Promise<any[]> {
  const api = giteaApi(user);
  const repos: any[] = [];
  let page = 1;
  while (true) {
    const res = await api.get(`/user/repos?limit=50&page=${page}`);
    const data = Array.isArray(res.data) ? res.data : [];
    if (data.length === 0) break;
    repos.push(...data);
    if (data.length < 50) break;
    page++;
  }
  return repos;
}

export async function fetchRepo(user: User, owner: string, repo: string): Promise<any> {
  const api = giteaApi(user);
  const res = await api.get(`/repos/${owner}/${repo}`);
  return res.data;
}

// The canonical event set for every PP-managed webhook. Gitea splits PR events:
// "pull_request" fires on opened/closed/reopened/edited, but a push of new
// commits to the PR branch fires the SEPARATE "pull_request_sync" event.
// Without it, the synchronize webhook is never delivered and previews never
// redeploy on push. "issue_comment" carries the `/pp ...` commands.
export const PP_WEBHOOK_EVENTS = ["pull_request", "pull_request_sync", "issue_comment"];

// Human-readable label shown in Gitea's webhook list. Applied on creation and
// backfilled onto older unnamed webhooks during boot reconciliation.
export const PP_WEBHOOK_NAME = "PR Previews";

export async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise<number> {
  const api = giteaApi(user);
  const res = await api.post(`/repos/${owner}/${repo}/hooks`, {
    type: "gitea",
    name: PP_WEBHOOK_NAME,
    config: {
      url: webhookUrl,
      secret,
      content_type: "json",
    },
    events: PP_WEBHOOK_EVENTS,
    active: true,
  });
  return res.data.id;
}

export async function deleteWebhook(user: User, owner: string, repo: string, hookId: string): Promise<void> {
  const api = giteaApi(user);
  await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);
}

export async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise<void> {
  const api = giteaApi(user);
  const hook = await getWebhook(user, owner, repo, hookId);
  if (!hook) throw new Error("Webhook no longer exists in Gitea");

  const currentEvents: string[] = Array.isArray(hook.events) ? hook.events : [];
  await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {
    name: PP_WEBHOOK_NAME,
    config: {
      url: webhookUrl,
      secret: newSecret,
      content_type: "json",
    },
    events: [...new Set([...currentEvents, ...PP_WEBHOOK_EVENTS])],
    active: true,
  });
}

// Fetches a single webhook. Returns null on 404 (webhook deleted in Gitea while
// PP still has its id on the RepoConfig).
export async function getWebhook(user: User, owner: string, repo: string, hookId: string): Promise<any | null> {
  const api = giteaApi(user);
  try {
    const res = await api.get(`/repos/${owner}/${repo}/hooks/${hookId}`);
    return res.data;
  } catch (e: any) {
    if (e?.response?.status === 404) return null;
    throw e;
  }
}

export type WebhookReconcileResult =
  | { status: "missing" }
  | { status: "ok" }
  | { status: "patched"; addedEvents: string[]; named: boolean; reactivated: boolean };

// Brings an already-registered webhook up to the current PP standard without
// tearing it down: unions in any missing events (chiefly "pull_request_sync"
// for webhooks created before that fix), names it if it was left unnamed, and
// re-activates it if disabled. User-added extra events are preserved. Patches
// only when something actually differs, so re-runs are no-ops.
export async function reconcileWebhook(user: User, owner: string, repo: string, hookId: string): Promise<WebhookReconcileResult> {
  const hook = await getWebhook(user, owner, repo, hookId);
  if (!hook) return { status: "missing" };

  const currentEvents: string[] = Array.isArray(hook.events) ? hook.events : [];
  const missingEvents = PP_WEBHOOK_EVENTS.filter(e => !currentEvents.includes(e));
  const hasName = typeof hook.name === "string" && hook.name.trim().length > 0;
  const inactive = hook.active === false;

  if (missingEvents.length === 0 && hasName && !inactive) return { status: "ok" };

  // Gitea's edit-hook API resets an omitted/empty `events` array to push-only,
  // silently disabling every other trigger. So ANY patch must re-send the full
  // desired event set (union of existing + PP events) and `active` — never a
  // partial body that would wipe the triggers we depend on.
  const patch: any = {
    events: [...new Set([...currentEvents, ...PP_WEBHOOK_EVENTS])],
    active: true,
  };
  if (!hasName) patch.name = PP_WEBHOOK_NAME;

  const api = giteaApi(user);
  await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, patch);

  return { status: "patched", addedEvents: missingEvents, named: !hasName, reactivated: inactive };
}

export async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise<number> {
  const api = giteaApi(user);
  try {
    const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });
    return res.data.id;
  } catch (e: any) {
    throw giteaWriteError(e, "posting a PR comment");
  }
}

export async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise<void> {
  const api = giteaApi(user);
  try {
    await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
  } catch (e: any) {
    throw giteaWriteError(e, "updating a PR comment");
  }
}

export async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise<boolean> {
  try {
    const api = giteaApi(user);
    const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`);
    return res.status === 204;
  } catch {
    return false;
  }
}

export async function getRepoCollaboratorPermission(user: User, owner: string, repo: string, username: string): Promise<string | null> {
  try {
    const api = giteaApi(user);
    const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`);
    return res.data?.permission ?? null;
  } catch {
    return null;
  }
}

export function buildPrCommentBody(opts: {
  owner: string;
  repo: string;
  prNumber: number;
  status: string;
  commitSha: string;
  updatedAt: Date;
  ppBaseUrl: string;
  lastLogLines?: string;
  instanceIp?: string;
  port?: number;
}): string {
  const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts;
  const ts = updatedAt.toISOString().replace("T", " ").slice(0, 19) + " UTC";

  let statusLine = status;
  if (lastLogLines) {
    statusLine += `\n\n\`\`\`\n${lastLogLines}\n\`\`\``;
  }

  return `## 🚀 PR Preview — \`${owner}/${repo}\` #${prNumber}

**Status:** ${statusLine}
**Commit:** \`${commitSha.slice(0, 8)}\`
**Updated:** ${ts}

---
_Powered by [PR Previews](${ppBaseUrl})_`;
}
