const fs = require("node:fs");

const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";

const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const REVIEWER_LOGIN = "gschier";

const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const SUMMARY_TITLE_MAX_LENGTH = 80;
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";

const LABELS = {
  inScope: {
    name: "contribution: in scope",
    color: "0E8A16",
    description: "Community PR appears to be in scope for maintainer review.",
  },
  outOfScope: {
    name: "contribution: out of scope",
    color: "B60205",
    description: "Community PR does not match Yaak's contribution policy.",
  },
  explicitPermission: {
    name: "contribution: explicit permission",
    color: "5319E7",
    description:
      "Community PR links feedback where @gschier explicitly allowed the work.",
  },
  missingTemplate: {
    name: "contribution: missing template",
    color: "D93F0B",
    description:
      "Community PR is missing enough of the pull request template to review.",
  },
  policyUnmet: {
    name: "contribution: policy unmet",
    color: "B60205",
    description:
      "Community PR does not currently satisfy the contribution policy.",
  },
  needsScopeReview: {
    name: "contribution: needs scope review",
    color: "FBCA04",
    description:
      "Community PR may be broader than Yaak's bug-fix contribution policy.",
  },
};

const MANAGED_LABEL_NAMES = [
  ...new Set(Object.values(LABELS).map((label) => label.name)),
];

const CHECKBOXES = {
  bugFix: "This PR is a bug fix.",
  explicitPermission:
    "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
  readContributing:
    "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
  testedLocally: "I tested this change locally.",
  testsUpdated: "I added or updated tests when reasonable.",
  screenshotsAdded:
    "I added screenshots or recordings for UI changes when reasonable.",
};

function escapeRegExp(value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function normalizeBody(body) {
  return (body || "").replace(/\r\n/g, "\n");
}

function stripComments(value) {
  return value.replace(/<!--[\s\S]*?-->/g, "").trim();
}

function getSection(body, heading) {
  const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
  const match = pattern.exec(body);

  if (match == null) {
    return null;
  }

  const rest = body.slice(match.index + match[0].length);
  const nextHeadingIndex = rest.search(/^##\s+/m);
  return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
}

function hasMeaningfulText(value) {
  return stripComments(value || "").length > 0;
}

function normalizeCheckboxLabel(label) {
  return label
    .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
    .replace(/`/g, "")
    .replace(/\s+/g, " ")
    .trim();
}

function checkboxState(body, label) {
  const expectedLabel = normalizeCheckboxLabel(label);

  for (const line of body.split("\n")) {
    const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);

    if (match == null) {
      continue;
    }

    if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
      return match[1].toLowerCase() === "x";
    }
  }

  return null;
}

function findFeedbackUrl(body) {
  return (
    body.match(
      /https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
    )?.[0] ?? null
  );
}

function getLabelNames(pr) {
  return new Set((pr.labels || []).map((label) => label.name));
}

function analyzePullRequest(pr) {
  const body = normalizeBody(pr.body);
  const labelNames = getLabelNames(pr);
  const states = Object.fromEntries(
    Object.entries(CHECKBOXES).map(([key, label]) => [
      key,
      checkboxState(body, label),
    ]),
  );
  const sectionCount = ["Summary", "Submission", "Related"].filter(
    (heading) => getSection(body, heading) != null,
  ).length;
  const checkboxCount = Object.values(states).filter(
    (state) => state != null,
  ).length;
  const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
  const blockers = [];
  const totalChangedLines =
    Number(pr.additions || 0) + Number(pr.deletions || 0);
  const changedFiles = Number(pr.changed_files || 0);
  const largeDiff =
    changedFiles > LARGE_DIFF_CHANGED_FILES ||
    totalChangedLines > LARGE_DIFF_CHANGED_LINES;

  if (labelNames.has(LABELS.outOfScope.name)) {
    return {
      blockers: [
        {
          label: LABELS.outOfScope.name,
          message: "Marked out of scope by maintainer label.",
        },
      ],
      changedFiles,
      desiredLabels: [LABELS.outOfScope.name],
      largeDiff,
      status: "out_of_scope",
      templateUsed,
      totalChangedLines,
    };
  }

  if (labelNames.has(LABELS.inScope.name)) {
    return {
      blockers: [],
      changedFiles,
      desiredLabels: [LABELS.inScope.name],
      largeDiff,
      status: "in_scope",
      templateUsed,
      totalChangedLines,
    };
  }

  if (!templateUsed) {
    blockers.push({
      label: LABELS.missingTemplate.name,
      message:
        "Update the PR description with the repository pull request template.",
    });
  } else {
    const summary = getSection(body, "Summary");
    const hasSummary = hasMeaningfulText(summary);
    const feedbackUrl = findFeedbackUrl(body);
    const bugFix = states.bugFix === true;
    const explicitPermission = states.explicitPermission === true;

    if (!hasSummary) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message:
          "Add a short summary describing the bug fix or permitted change.",
      });
    }

    if (bugFix && explicitPermission) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message:
          "Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
      });
    } else if (!bugFix && !explicitPermission) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message:
          "Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
      });
    } else if (explicitPermission && feedbackUrl == null) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message:
          "Link the feedback item where @gschier explicitly gave you permission to work on this.",
      });
    }

    if (states.readContributing !== true) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message: "Confirm that `CONTRIBUTING.md` was read and followed.",
      });
    }

    if (states.testedLocally !== true) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message: "Confirm that the change was tested locally.",
      });
    }

    if (states.testsUpdated !== true) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message: "Confirm that tests were added or updated when reasonable.",
      });
    }

    if (states.screenshotsAdded !== true) {
      blockers.push({
        label: LABELS.policyUnmet.name,
        message:
          "Confirm that screenshots or recordings were added for UI changes when reasonable.",
      });
    }
  }

  const desiredLabels = new Set();

  if (blockers.length === 0) {
    desiredLabels.add(
      largeDiff
        ? LABELS.needsScopeReview.name
        : states.explicitPermission
          ? LABELS.explicitPermission.name
          : LABELS.inScope.name,
    );
  } else if (
    blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
  ) {
    desiredLabels.add(LABELS.missingTemplate.name);
  } else {
    desiredLabels.add(LABELS.policyUnmet.name);
  }

  return {
    blockers,
    changedFiles,
    desiredLabels: [...desiredLabels],
    largeDiff,
    status: blockers.length === 0 ? "in_scope" : "blocked",
    templateUsed,
    totalChangedLines,
  };
}

function buildBlockingComment(analysis) {
  const lines = [
    COMMENT_MARKER,
    "Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
    "",
    "This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
    "",
    ...analysis.blockers.map((blocker) => `- ${blocker.message}`),
  ];

  if (!analysis.templateUsed) {
    lines.push(
      "",
      "You can copy this template into the PR description and keep any existing context that is still useful.",
      "",
      "<details>",
      "<summary>PR description template</summary>",
      "",
      "```md",
      getPullRequestTemplate(),
      "```",
      "",
      "</details>",
    );
  }

  if (analysis.largeDiff) {
    lines.push(
      "",
      `This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
    );
  }

  return lines.join("\n");
}

function getPullRequestTemplate() {
  return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
}

function buildInScopeComment() {
  return [
    COMMENT_MARKER,
    "Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
    "",
    "This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
  ].join("\n");
}

function buildOutOfScopeComment() {
  return [
    COMMENT_MARKER,
    "Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
    "",
    "Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
    "",
    "If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
  ].join("\n");
}

function buildPolicyComment(analysis) {
  if (analysis.status === "out_of_scope") {
    return buildOutOfScopeComment();
  }

  if (analysis.blockers.length > 0) {
    return buildBlockingComment(analysis);
  }

  return buildInScopeComment();
}

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function truncateTitle(title) {
  if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
    return title;
  }

  return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
}

function escapeTableText(value) {
  return escapeHtml(value).replace(/\n/g, "<br>");
}

function summarizeResult({ pr, analysis, skipped, skipReason }) {
  const comment =
    analysis == null
      ? "None"
      : buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
  const summary = {
    blocked: analysis?.blockers.length > 0,
    comment,
    details: "None",
    labels:
      analysis?.desiredLabels.length > 0
        ? analysis.desiredLabels.join(", ")
        : "None",
    number: pr.number,
    prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
    status: "In scope",
    title: escapeHtml(truncateTitle(pr.title)),
  };

  if (skipped) {
    return {
      ...summary,
      blocked: false,
      comment: "None",
      details: escapeHtml(skipReason),
      labels: "None",
      status: "Skipped",
    };
  }

  if (summary.blocked) {
    return {
      ...summary,
      comment: escapeTableText(summary.comment),
      details: escapeHtml(
        analysis.blockers.map((blocker) => blocker.message).join("; "),
      ),
      labels: escapeHtml(summary.labels),
      status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
    };
  }

  return {
    ...summary,
    comment: escapeTableText(summary.comment),
    labels: escapeHtml(summary.labels),
  };
}

function wasCreatedBefore(value, cutoff) {
  return Date.parse(value) < Date.parse(cutoff);
}

async function isOfficialMaintainer({ github, owner, repo, pr }) {
  if (MAINTAINER_LOGINS.has(pr.user.login)) {
    return true;
  }

  if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
    return true;
  }

  try {
    const response = await github.rest.repos.getCollaboratorPermissionLevel({
      owner,
      repo,
      username: pr.user.login,
    });

    return MAINTAINER_PERMISSIONS.has(response.data.permission);
  } catch (error) {
    if (error.status === 404) {
      return false;
    }

    throw error;
  }
}

async function ensureManagedLabels({ github, owner, repo }) {
  for (const label of Object.values(LABELS)) {
    try {
      await github.rest.issues.getLabel({
        owner,
        repo,
        name: label.name,
      });
    } catch (error) {
      if (error.status !== 404) {
        throw error;
      }

      await github.rest.issues.createLabel({
        owner,
        repo,
        name: label.name,
        color: label.color,
        description: label.description,
      });
    }
  }
}

async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
  const desired = new Set(desiredLabels);

  await ensureManagedLabels({ github, owner, repo });

  for (const labelName of MANAGED_LABEL_NAMES) {
    if (desired.has(labelName)) {
      continue;
    }

    try {
      await github.rest.issues.removeLabel({
        owner,
        repo,
        issue_number: issueNumber,
        name: labelName,
      });
    } catch (error) {
      if (error.status !== 404) {
        throw error;
      }
    }
  }

  if (desired.size > 0) {
    await github.rest.issues.addLabels({
      owner,
      repo,
      issue_number: issueNumber,
      labels: [...desired],
    });
  }
}

async function findPolicyComment({ github, owner, repo, issueNumber }) {
  const comments = await github.paginate(github.rest.issues.listComments, {
    owner,
    repo,
    issue_number: issueNumber,
    per_page: 100,
  });

  return comments.find(
    (comment) =>
      comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
  );
}

async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
  const existingComment = await findPolicyComment({
    github,
    owner,
    repo,
    issueNumber,
  });

  if (existingComment == null) {
    await github.rest.issues.createComment({
      owner,
      repo,
      issue_number: issueNumber,
      body,
    });
    return;
  }

  await github.rest.issues.updateComment({
    owner,
    repo,
    comment_id: existingComment.id,
    body,
  });
}

async function deletePolicyComment({ github, owner, repo, issueNumber }) {
  const existingComment = await findPolicyComment({
    github,
    owner,
    repo,
    issueNumber,
  });

  if (existingComment == null) {
    return;
  }

  await github.rest.issues.deleteComment({
    owner,
    repo,
    comment_id: existingComment.id,
  });
}

async function requestMaintainerReview({ github, owner, repo, pr }) {
  if (pr.user.login === REVIEWER_LOGIN) {
    return;
  }

  try {
    await github.rest.pulls.requestReviewers({
      owner,
      repo,
      pull_number: pr.number,
      reviewers: [REVIEWER_LOGIN],
    });
  } catch (error) {
    if (error.status === 422) {
      return;
    }

    throw error;
  }
}

async function checkPullRequest({
  github,
  core,
  owner,
  repo,
  pullNumber,
  dryRun,
  skipCreatedBefore,
}) {
  const response = await github.rest.pulls.get({
    owner,
    repo,
    pull_number: pullNumber,
  });
  const pr = response.data;
  const issueNumber = pr.number;

  if (
    skipCreatedBefore != null &&
    wasCreatedBefore(pr.created_at, skipCreatedBefore)
  ) {
    core.notice(
      `Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
    );
    return {
      blocked: false,
      number: pr.number,
      summary: summarizeResult({
        pr,
        skipped: true,
        skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
      }),
      skipped: true,
    };
  }

  if (pr.draft) {
    core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
    return {
      blocked: false,
      number: pr.number,
      summary: summarizeResult({
        pr,
        skipped: true,
        skipReason: "draft",
      }),
      skipped: true,
    };
  }

  if (await isOfficialMaintainer({ github, owner, repo, pr })) {
    core.notice(
      `Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
    );
    if (!dryRun) {
      await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
      await deletePolicyComment({ github, owner, repo, issueNumber });
    }
    return {
      blocked: false,
      number: pr.number,
      summary: summarizeResult({
        pr,
        skipped: true,
        skipReason: `maintainer @${pr.user.login}`,
      }),
      skipped: true,
    };
  }

  const analysis = analyzePullRequest(pr);

  if (dryRun) {
    const summary = summarizeResult({ pr, analysis });
    core.notice(
      `[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
    );
    return {
      blocked: analysis.blockers.length > 0,
      number: pr.number,
      summary,
      skipped: false,
    };
  }

  await syncLabels({
    github,
    owner,
    repo,
    issueNumber,
    desiredLabels: analysis.desiredLabels,
  });

  if (analysis.blockers.length > 0) {
    await upsertPolicyComment({
      github,
      owner,
      repo,
      issueNumber,
      body: buildPolicyComment(analysis),
    });
    return {
      blocked: true,
      number: pr.number,
      summary: summarizeResult({ pr, analysis }),
      skipped: false,
    };
  }

  await upsertPolicyComment({
    github,
    owner,
    repo,
    issueNumber,
    body: buildPolicyComment(analysis),
  });
  await requestMaintainerReview({ github, owner, repo, pr });
  core.notice(`Contribution policy check passed for PR #${pr.number}.`);
  return {
    blocked: false,
    number: pr.number,
    summary: summarizeResult({ pr, analysis }),
    skipped: false,
  };
}

async function listOpenPullRequests({ github, owner, repo }) {
  return github.paginate(github.rest.pulls.list, {
    owner,
    repo,
    state: "open",
    per_page: 100,
  });
}

function getManualPullRequestNumbers({ context, core }) {
  const value = String(context.payload.inputs?.pr || "all").trim();

  if (value.toLowerCase() === "all") {
    return null;
  }

  const pullNumber = Number(value);

  if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
    core.setFailed('The "pr" input must be "all" or a positive PR number.');
    return [];
  }

  return [pullNumber];
}

async function run({ github, context, core }) {
  const { owner, repo } = context.repo;
  const payloadPr = context.payload.pull_request;
  const dryRunInput = context.payload.inputs?.dry_run;
  const dryRun =
    context.eventName === "workflow_dispatch" &&
    dryRunInput !== false &&
    dryRunInput !== "false";
  const skipCreatedBefore =
    payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
  let pullNumbers;

  if (payloadPr != null) {
    pullNumbers = [payloadPr.number];
  } else {
    pullNumbers = getManualPullRequestNumbers({ context, core });
  }

  if (pullNumbers?.length === 0) {
    return;
  }

  const pullRequests =
    pullNumbers == null
      ? await listOpenPullRequests({ github, owner, repo })
      : pullNumbers.map((number) => ({ number }));
  const results = [];

  if (dryRun) {
    core.notice(
      `Running contribution policy in dry-run mode for ${
        pullNumbers == null
          ? "all open PRs"
          : pullNumbers.map((number) => `#${number}`).join(", ")
      }.`,
    );
  }

  for (const pr of pullRequests) {
    results.push(
      await checkPullRequest({
        github,
        core,
        owner,
        repo,
        pullNumber: pr.number,
        dryRun,
        skipCreatedBefore,
      }),
    );
  }

  await core.summary
    .addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
    .addTable([
      [
        { data: "PR", header: true },
        { data: "Title", header: true },
        { data: "Status", header: true },
        { data: "Labels", header: true },
        { data: "Details", header: true },
        { data: "Comment", header: true },
      ],
      ...results.map((result) => [
        result.summary.prLink,
        result.summary.title,
        result.summary.status,
        result.summary.labels,
        result.summary.details,
        result.summary.comment,
      ]),
    ])
    .write();

  const blockedPullRequests = results.filter((result) => result.blocked);

  if (blockedPullRequests.length > 0) {
    if (dryRun) {
      core.warning(
        `Dry run found contribution policy failures for PR(s): ${blockedPullRequests
          .map((result) => `#${result.number}`)
          .join(", ")}`,
      );
      return;
    }

    core.setFailed(
      `Contribution policy failed for PR(s): ${blockedPullRequests
        .map((result) => `#${result.number}`)
        .join(", ")}`,
    );
  }
}

module.exports = {
  analyzePullRequest,
  run,
};
