import { createHmac } from "crypto";
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { getAdminSettings } from "../lib/adminSettings";
import { buildPrCommentBody, postComment } from "../services/gitea";
import { env } from "../lib/env";

const log = createLogger("WEBHOOK");

const rateLimitMap = new Map<number, { count: number; resetAt: number }>();

function checkRateLimit(userId: number, limitPerMin: number): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(userId);
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(userId, { count: 1, resetAt: now + 60_000 });
    return true;
  }
  if (entry.count >= limitPerMin) return false;
  entry.count++;
  return true;
}

export async function webhookHandler(ctr: any) {
  const userId = parseInt(ctr.params.get("userId") || "0", 10);
  if (!userId) return ctr.status(400).print({ status: "FAILED", message: "Invalid user ID" });

  const settings = await getAdminSettings();

  if (!checkRateLimit(userId, settings.webhookRateLimitPerMinute)) {
    return ctr.status(429).print({ status: "FAILED", message: "Rate limit exceeded" });
  }

  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) return ctr.status(404).print({ status: "FAILED", message: "User not found" });

  const webhookToken = await prisma.webhookToken.findUnique({ where: { userId } });
  if (!webhookToken) return ctr.status(401).print({ status: "FAILED", message: "No webhook token configured" });

  const signature = ctr.headers.get("x-gitea-signature-256") || ctr.headers.get("x-hub-signature-256") || "";
  const rawBody = await ctr.$body().text();

  const expected = "sha256=" + createHmac("sha256", webhookToken.token).update(rawBody).digest("hex");
  if (!timingSafeEqual(signature, expected)) {
    log.warn({ userId, signature }, "Invalid webhook signature");
    return ctr.status(401).print({ status: "FAILED", message: "Invalid signature" });
  }

  let payload: any;
  try {
    payload = JSON.parse(rawBody);
  } catch {
    return ctr.status(400).print({ status: "FAILED", message: "Invalid JSON payload" });
  }

  const event = ctr.headers.get("x-gitea-event") || "";

  ctr.status(200).print({ status: "OK" });

  setImmediate(() => handleWebhookAsync(user, event, payload).catch(e => log.error({ e }, "Webhook processing error")));
}

async function handleWebhookAsync(user: any, event: string, payload: any) {
  if (event === "pull_request") {
    await handlePullRequestEvent(user, payload);
  } else if (event === "issue_comment") {
    await handleIssueCommentEvent(user, payload);
  }
}

async function handlePullRequestEvent(user: any, payload: any) {
  const action = payload.action;
  const pr = payload.pull_request;
  const repo = payload.repository;
  if (!pr || !repo) return;

  const owner = repo.owner?.login || repo.full_name?.split("/")[0];
  const repoName = repo.name;
  const prNumber = pr.number;
  const prTitle = pr.title || `PR #${prNumber}`;
  const commitSha = pr.head?.sha || "";
  const cloneUrl = pr.head?.repo?.clone_url || repo.clone_url;

  const repoConfig = await prisma.repoConfig.findFirst({
    where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },
  });

  if (!repoConfig) {
    // Only post "no config" comment on new PR opens (not on sync/close events)
    if (action === "opened" || action === "reopened") {
      const existing = await prisma.noConfigComment.findUnique({
        where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },
      });
      if (!existing) {
        const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`;
        try {
          await postComment(user, owner, repoName, prNumber, body);
          await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });
        } catch {}
      }
    }
    return;
  }

  if (repoConfig.denyList.includes(pr.user?.login || "")) return;

  if (action === "opened" || action === "reopened") {
    let preview = await prisma.preview.findFirst({
      where: { repoConfigId: repoConfig.id, prNumber },
      orderBy: { createdAt: "desc" },
    });

    if (preview && preview.status === "IGNORED") return;

    if (!preview || preview.status === "STOPPED") {
      preview = await prisma.preview.create({
        data: {
          repoConfigId: repoConfig.id,
          prNumber,
          prTitle,
          commitSha,
          status: "PROVISIONING",
          port: repoConfig.port,
        },
      });

      await prisma.job.create({
        data: {
          previewId: preview.id,
          type: "DEPLOY",
          status: "PENDING",
          payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: true },
        },
      });
    }
  } else if (action === "synchronized" || action === "synchronize") {
    // Gitea sends "synchronized" (past tense) on a push to the PR branch;
    // GitHub uses "synchronize". Accept both so the redeploy fires either way.
    const preview = await prisma.preview.findFirst({
      where: { repoConfigId: repoConfig.id, prNumber },
      orderBy: { createdAt: "desc" },
    });

    if (!preview || preview.status === "IGNORED") return;

    await prisma.preview.update({ where: { id: preview.id }, data: { lastActivityAt: new Date() } });

    await prisma.job.create({
      data: {
        previewId: preview.id,
        type: "DEPLOY",
        status: "PENDING",
        payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: preview.status === "STOPPED" },
      },
    });
  } else if (action === "closed") {
    const preview = await prisma.preview.findFirst({
      where: { repoConfigId: repoConfig.id, prNumber },
      orderBy: { createdAt: "desc" },
    });

    if (preview && preview.status !== "STOPPED" && preview.status !== "IGNORED") {
      await prisma.job.create({
        data: {
          previewId: preview.id,
          type: "STOP",
          status: "PENDING",
          payload: { reason: "PR closed" },
        },
      });
    }
  }
}

async function handleIssueCommentEvent(user: any, payload: any) {
  const action = payload.action;
  if (action !== "created") return;

  const comment = payload.comment;
  const issue = payload.issue;
  const repo = payload.repository;
  if (!comment || !issue || !repo || !issue.pull_request) return;

  const body = comment.body || "";
  if (!body.trimStart().startsWith("/pp ")) return;

  const owner = repo.owner?.login || repo.full_name?.split("/")[0];
  const repoName = repo.name;
  const prNumber = issue.number;

  // Note: we intentionally do NOT ignore comments authored by PP's own Gitea
  // account here. Loop-prevention is already handled by the `/pp ` prefix check
  // above — PP's own comments (status updates, `/pp logs` output) never start
  // with `/pp `, so they can't re-trigger a command. Guarding on giteaUsername
  // instead only blocked legitimate commands from operators who run PP under
  // the same account they comment from.

  const repoConfig = await prisma.repoConfig.findFirst({
    where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },
  });
  if (!repoConfig) return;

  const commenter = comment.user?.login || "";
  const prAuthor = issue.user?.login || "";
  const isAllowed = commenter === prAuthor || (await isRepoAdmin(user, owner, repoName, commenter));
  if (!isAllowed) return;

  await prisma.preview.updateMany({
    where: {
      repoConfigId: repoConfig.id,
      prNumber,
      status: { in: ["RUNNING", "BUILDING", "FAILED", "PROVISIONING"] },
    },
    data: { lastActivityAt: new Date() },
  });

  // First token after "/pp " is the command word; everything else is ignored.
  const commandLine = body.trimStart().split("\n")[0].trim();
  const command = commandLine.replace(/^\/pp\s+/, "").trim().split(/\s+/)[0].toLowerCase();

  const preview = await prisma.preview.findFirst({
    where: { repoConfigId: repoConfig.id, prNumber },
    orderBy: { createdAt: "desc" },
  });

  const cloneUrl = repo.clone_url;

  // Operator-configured per-repo command switches. `help` can never be disabled
  // so users always retain a way to discover what is (and isn't) available.
  const disabled: string[] = Array.isArray(repoConfig.disabledCommands) ? repoConfig.disabledCommands : [];
  if (command !== "help" && disabled.includes(command)) {
    await ack(user, owner, repoName, prNumber, `⚠️ The \`/pp ${command}\` command is disabled for this repository.`);
    return;
  }

  if (command === "help") {
    await ack(user, owner, repoName, prNumber, buildHelpBody(disabled));
  } else if (command === "rebuild") {
    if (!preview) {
      await ack(user, owner, repoName, prNumber, "ℹ️ No preview exists for this PR yet. Use `/pp start` to create one.");
      return;
    }
    if (preview.status === "IGNORED") return;
    if (preview.status === "STOPPED") {
      // Nothing live to reuse — re-provision a fresh instance (first deploy).
      await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING", stopReason: null, stoppedAt: null } });
      await prisma.job.create({
        data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } },
      });
      await ack(user, owner, repoName, prNumber, "🔄 Preview was stopped — re-provisioning a fresh instance...");
      return;
    }
    // RUNNING / FAILED / BUILDING / PROVISIONING: rebuild on the existing instance.
    await prisma.job.create({
      data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false } },
    });
    await ack(user, owner, repoName, prNumber, "🔄 Rebuilding preview on the existing instance...");
  } else if (command === "stop") {
    if (!preview || preview.status === "STOPPED") {
      await ack(user, owner, repoName, prNumber, "ℹ️ No running preview to stop.");
      return;
    }
    await prisma.job.create({
      data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } },
    });
    await ack(user, owner, repoName, prNumber, "🛑 Stopping preview...");
  } else if (command === "start") {
    if (!preview) {
      const newPreview = await prisma.preview.create({
        data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "PROVISIONING", port: repoConfig.port },
      });
      await prisma.job.create({
        data: { previewId: newPreview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: "", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } },
      });
      await ack(user, owner, repoName, prNumber, "🚀 Starting preview...");
    } else if (preview.status === "STOPPED" || preview.status === "IGNORED") {
      await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING", stopReason: null, stoppedAt: null } });
      await prisma.job.create({
        data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } },
      });
      await ack(user, owner, repoName, prNumber, "🚀 Starting preview...");
    } else {
      await ack(user, owner, repoName, prNumber, `ℹ️ Preview is already \`${preview.status.toLowerCase()}\`.`);
    }
  } else if (command === "logs") {
    if (!preview) {
      await ack(user, owner, repoName, prNumber, "ℹ️ No preview exists for this PR yet.");
      return;
    }
    const lastLines = (preview.logs || "").split("\n").slice(-50).join("\n");
    const logBody = `**PP Logs** (last 50 lines)\n\`\`\`\n${lastLines}\n\`\`\``;
    await postComment(user, owner, repoName, prNumber, logBody);
  } else if (command === "ignore") {
    if (!preview) {
      await prisma.preview.create({
        data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "IGNORED", port: repoConfig.port },
      });
    } else {
      await prisma.preview.update({ where: { id: preview.id }, data: { status: "IGNORED" } });
    }
    await ack(user, owner, repoName, prNumber, "🔕 This PR is now ignored. Future pushes and commands (except `/pp start`) will be skipped.");
  } else {
    await ack(user, owner, repoName, prNumber, `❓ Unknown command \`/pp ${command}\`. Try \`/pp help\`.`);
  }
}

// Posts a short acknowledgement comment for a command. Best-effort: a Gitea
// failure here must never bubble up and break command processing.
async function ack(user: any, owner: string, repo: string, prNumber: number, message: string): Promise<void> {
  try {
    await postComment(user, owner, repo, prNumber, message);
  } catch (e) {
    log.warn({ e, owner, repo, prNumber }, "Failed to post command acknowledgement comment");
  }
}

// The canonical `/pp` command list, also used to render `/pp help`.
export const PP_COMMANDS: { name: string; usage: string; description: string }[] = [
  { name: "rebuild", usage: "/pp rebuild", description: "Rebuild the preview — reuses the running instance, or re-provisions if stopped." },
  { name: "stop", usage: "/pp stop", description: "Stop and terminate the preview instance." },
  { name: "start", usage: "/pp start", description: "Start a stopped/ignored preview, or create one if none exists." },
  { name: "logs", usage: "/pp logs", description: "Post the last 50 lines of build/run logs as a comment." },
  { name: "ignore", usage: "/pp ignore", description: "Ignore this PR — skip future pushes and commands (except /pp start)." },
  { name: "help", usage: "/pp help", description: "Show this command reference." },
];

function buildHelpBody(disabled: string[]): string {
  const rows = PP_COMMANDS.map(c => {
    const off = c.name !== "help" && disabled.includes(c.name);
    const cmd = off ? `~~\`${c.usage}\`~~` : `\`${c.usage}\``;
    const desc = off ? `_(disabled for this repo)_ ${c.description}` : c.description;
    return `| ${cmd} | ${desc} |`;
  }).join("\n");
  return `## 🤖 PR Previews — Commands

Only the PR author or a repo owner/admin can run these.

| Command | Description |
| --- | --- |
${rows}`;
}

async function isRepoAdmin(user: any, owner: string, repo: string, username: string): Promise<boolean> {
  try {
    const { getRepoCollaboratorPermission } = await import("../services/gitea");
    const permission = await getRepoCollaboratorPermission(user, owner, repo, username);
    return permission === "owner" || permission === "admin";
  } catch {
    return false;
  }
}

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}
