module.exports = async ({ github, context, core, dry }) => {
  const path = require('node:path')
  const { DefaultArtifactClient } = require('@actions/artifact')
  const { readFile, writeFile } = require('node:fs/promises')
  const withRateLimit = require('./withRateLimit.js')
  const { classify } = require('../supportedBranches.js')
  const { handleMerge } = require('./merge.js')
  const { handleReviewers } = require('./reviewers.js')

  const artifactClient = new DefaultArtifactClient()

  // Detect if running in a fork (not NixOS/nixpkgs)
  const isFork = context.repo.owner !== 'NixOS'

  async function downloadMaintainerMap(branch) {
    let run

    const commits = (
      await github.rest.repos.listCommits({
        ...context.repo,
        sha: branch,
        // We look at 10 commits to find a maintainer map, but this is an arbitrary number. The
        // head commit might not have a map, if the queue was bypassed to merge it. This happens
        // frequently on staging-esque branches. The branch with the highest chance of getting
        // 10 consecutive bypassing commits is the stable staging-next branch. Luckily, this
        // also means that the number of PRs open towards that branch is very low, so falling
        // back to slightly imprecise maintainer data from master only has a marginal effect.
        per_page: 10,
      })
    ).data

    for (const commit of commits) {
      const run = (
        await github.rest.actions.listWorkflowRuns({
          ...context.repo,
          workflow_id: 'merge-group.yml',
          status: 'success',
          exclude_pull_requests: true,
          per_page: 1,
          head_sha: commit.sha,
        })
      ).data.workflow_runs[0]
      if (!run) continue

      const artifact = (
        await github.rest.actions.listWorkflowRunArtifacts({
          ...context.repo,
          run_id: run.id,
          name: 'maintainers',
        })
      ).data.artifacts[0]
      if (!artifact || artifact.expired) continue

      await artifactClient.downloadArtifact(artifact.id, {
        findBy: {
          repositoryName: context.repo.repo,
          repositoryOwner: context.repo.owner,
          token: core.getInput('github-token'),
        },
        path: path.resolve(path.join('branches', branch)),
        expectedHash: artifact.digest,
      })

      return JSON.parse(
        await readFile(
          path.resolve(path.join('branches', branch, 'maintainers.json')),
          'utf-8',
        ),
      )
    }

    // We get here when none of the 10 commits we looked at contained a maintainer map.
    // For the master branch, we don't have any fallback options, so we error out.
    // In forks without merge-group history, return empty map to allow testing.
    if (branch === 'master') {
      if (isFork) {
        core.warning(
          'No maintainer map found. Using empty map (expected in forks without merge-group history).',
        )
        return {}
      }
      throw new Error('No maintainer map found.')
    }

    // For other branches, we select a suitable fallback below.
    const { stable, version } = classify(branch)

    const release = `release-${version}`
    if (stable && branch !== release) {
      // Only fallback to the release branch from *other* stable branches.
      // Explicitly avoids infinite recursion.
      return await getMaintainerMap(release)
    } else {
      // Falling back to master as last resort.
      // This can either be the case for unstable staging-esque or wip branches,
      // or for the primary stable branch (release-XX.YY).
      return await getMaintainerMap('master')
    }
  }

  // Simple cache for maintainer maps to avoid downloading the same artifacts
  // over and over again. Ultimately returns a promise, so the result must be
  // awaited for.
  const maintainerMaps = {}
  function getMaintainerMap(branch) {
    if (!maintainerMaps[branch]) {
      maintainerMaps[branch] = downloadMaintainerMap(branch)
    }
    return maintainerMaps[branch]
  }

  // Caching the list of team members saves API requests when running the bot on the schedule and
  // processing many PRs at once.
  const members = {}
  function getTeamMembers(team_slug) {
    if (context.eventName === 'pull_request') {
      // We have no chance of getting a token in the pull_request context with the right
      // permissions to access the members endpoint below. Thus, we're pretending to have
      // no members. This is OK; because this is only for the Test workflow, not for
      // real use.
      return []
    }

    // Forks don't have NixOS teams, return empty list
    if (isFork) {
      return []
    }

    if (!members[team_slug]) {
      members[team_slug] = github.paginate(github.rest.teams.listMembersInOrg, {
        org: context.repo.owner,
        team_slug,
        per_page: 100,
      })
    }

    return members[team_slug]
  }

  // Caching users saves API requests when running the bot on the schedule and processing
  // many PRs at once. It also helps to encapsulate the special logic we need, because
  // actions/github doesn't support that endpoint fully, yet.
  const users = {}
  function getUser(id) {
    if (!users[id]) {
      users[id] = github
        .request({
          method: 'GET',
          url: '/user/{id}',
          id,
        })
        .then((resp) => resp.data)
        .catch((e) => {
          // User may have deleted their account
          if (e.status === 404) return null
          throw e
        })
    }

    return users[id]
  }

  async function handlePullRequest({ item, stats, events }) {
    const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)

    const pull_number = item.number

    // This API request is important for the merge-conflict label, because it triggers the
    // creation of a new test merge commit. This is needed to actually determine the state of a PR.
    const pull_request = (
      await github.rest.pulls.get({
        ...context.repo,
        pull_number,
      })
    ).data

    log('author', pull_request.user?.login)

    const maintainers = await getMaintainerMap(pull_request.base.ref)

    const merge_bot_eligible = await handleMerge({
      github,
      context,
      core,
      log,
      dry,
      pull_request,
      events,
      maintainers,
      getTeamMembers,
      getUser,
    })

    // Check for any human reviews other than the PR author, GitHub actions and other GitHub apps.
    // Accounts could be deleted as well, so don't count them.
    const reviews = (
      await github.paginate(github.rest.pulls.listReviews, {
        ...context.repo,
        pull_number,
      })
    ).filter(
      (r) =>
        r.user &&
        !r.user.login.endsWith('[bot]') &&
        r.user.type !== 'Bot' &&
        r.user.id !== pull_request.user?.id,
    )

    const approvals = new Set(
      reviews
        .filter((review) => review.state === 'APPROVED')
        .map((review) => review.user?.id),
    )

    // After creation of a Pull Request, `merge_commit_sha` will be null initially:
    // The very first merge commit will only be calculated after a little while.
    // To avoid labeling the PR as conflicted before that, we wait a few minutes.
    // This is intentionally less than the time that Eval takes, so that the label job
    // running after Eval can indeed label the PR as conflicted if that is the case.
    const merge_commit_sha_valid =
      Date.now() - new Date(pull_request.created_at) > 3 * 60 * 1000

    const prLabels = {
      // We intentionally don't use the mergeable or mergeable_state attributes.
      // Those have an intermediate state while the test merge commit is created.
      // This doesn't work well for us, because we might have just triggered another
      // test merge commit creation by request the pull request via API at the start
      // of this function.
      // The attribute merge_commit_sha keeps the old value of null or the hash *until*
      // the new test merge commit has either successfully been created or failed so.
      // This essentially means we are updating the merge conflict label in two steps:
      // On the first pass of the day, we just fetch the pull request, which triggers
      // the creation. At this stage, the label is likely not updated, yet.
      // The second pass will then read the result from the first pass and set the label.
      '2.status: merge conflict':
        merge_commit_sha_valid && !pull_request.merge_commit_sha,
      '2.status: merge-bot eligible': merge_bot_eligible,
      '12.approvals: 1': approvals.size === 1,
      '12.approvals: 2': approvals.size === 2,
      '12.approvals: 3+': approvals.size >= 3,
      '12.first-time contribution': [
        'NONE',
        'FIRST_TIMER',
        'FIRST_TIME_CONTRIBUTOR',
      ].includes(pull_request.author_association),
    }

    const { id: run_id, conclusion } =
      (
        await github.rest.actions.listWorkflowRuns({
          ...context.repo,
          workflow_id: 'pull-request-target.yml',
          event: 'pull_request_target',
          exclude_pull_requests: true,
          head_sha: pull_request.head.sha,
        })
      ).data.workflow_runs[0] ??
      // TODO: Remove this after 2026-02-01, at which point all pr.yml artifacts will have expired.
      (
        await github.rest.actions.listWorkflowRuns({
          ...context.repo,
          // In older PRs, we need pr.yml instead of pull-request-target.yml.
          workflow_id: 'pr.yml',
          event: 'pull_request_target',
          exclude_pull_requests: true,
          head_sha: pull_request.head.sha,
        })
      ).data.workflow_runs[0] ??
      {}

    // Newer PRs might not have run Eval to completion, yet.
    // Older PRs might not have an eval.yml workflow, yet.
    // In either case we continue without fetching an artifact on a best-effort basis.
    log('Last eval run', run_id ?? '<n/a>')

    if (conclusion === 'success') {
      Object.assign(prLabels, {
        // We only set this label if the latest eval run was successful, because if it was not, it
        // *could* have requested reviewers. We will let the PR author fix CI first, before "escalating"
        // this PR to "needs: reviewer".
        // Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently
        // updated" for the next scheduled run. Thus, this label will still be set within a few minutes
        // after a PR is created, if required.
        // Note that a "requested reviewer" disappears once they have given a review, so we check
        // existing reviews, too.
        '9.needs: reviewer':
          !pull_request.draft &&
          pull_request.requested_reviewers.length === 0 &&
          reviews.length === 0,
      })
    }

    const artifact =
      run_id &&
      (
        await github.rest.actions.listWorkflowRunArtifacts({
          ...context.repo,
          run_id,
          name: 'comparison',
        })
      ).data.artifacts[0]

    // Instead of checking the boolean artifact.expired, we will give us a minute to
    // actually download the artifact in the next step and avoid that race condition.
    // Older PRs, where the workflow run was already eval.yml, but the artifact was not
    // called "comparison", yet, will skip the download.
    const expired =
      !artifact ||
      new Date(artifact?.expires_at ?? 0) < new Date(Date.now() + 60 * 1000)
    log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
    if (!expired) {
      stats.artifacts++

      await artifactClient.downloadArtifact(artifact.id, {
        findBy: {
          repositoryName: context.repo.repo,
          repositoryOwner: context.repo.owner,
          token: core.getInput('github-token'),
        },
        path: path.resolve(pull_number.toString()),
        expectedHash: artifact.digest,
      })

      const changedPaths = JSON.parse(
        await readFile(`${pull_number}/changed-paths.json`, 'utf-8'),
      )
      const evalLabels = changedPaths.labels

      // Fetch all PR commits to check their messages for package patterns
      const prCommits = await github.paginate(github.rest.pulls.listCommits, {
        ...context.repo,
        pull_number,
        per_page: 100,
      })
      const commitSubjects = prCommits.map(
        (c) => c.commit.message.split('\n')[0],
      )

      // Label new package PRs: "packagename: init at X.Y.Z"
      // Exclude NixOS module commits like "nixos/timekpr: init at 0.5.8"
      const newPackagePattern = /^(?<!nixos\/)\S+: init at\b/
      const hasNewPackages = changedPaths.attrdiff?.added?.length > 0
      const commitsIndicateNewPackage = commitSubjects.some((msg) =>
        newPackagePattern.test(msg),
      )
      evalLabels['8.has: package (new)'] =
        hasNewPackages && commitsIndicateNewPackage

      // Label package update PRs: "packagename: X.Y.Z -> A.B.C"
      // Matches versions like: 1.2.3, 0-unstable-2024-01-15, 1.3rc1, alpha, unstable
      // Exclude NixOS module commits like "nixos/ncps: types.str -> types.path"
      const updatePackagePattern =
        /^(?<!nixos\/)\S+: [\w.-]*\d[\w.-]* (->|→) [\w.-]*\d[\w.-]*$/
      const commitsIndicateUpdate = commitSubjects.some((msg) =>
        updatePackagePattern.test(msg),
      )
      evalLabels['8.has: package (update)'] = commitsIndicateUpdate

      // TODO: Get "changed packages" information from list of changed by-name files
      // in addition to just the Eval results, to make this work for these packages
      // when Eval results have expired as well.
      let packages
      try {
        packages = JSON.parse(
          await readFile(`${pull_number}/packages.json`, 'utf-8'),
        )
      } catch (e) {
        if (e.code !== 'ENOENT') throw e
        // TODO: Remove this fallback code once all old artifacts without packages.json
        // have expired. This should be the case in ~ February 2026.
        packages = Array.from(
          new Set(
            Object.values(
              JSON.parse(
                await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
              ),
            ).flat(1),
          ),
        )
      }

      Object.assign(prLabels, evalLabels, {
        '11.by: package-maintainer':
          Boolean(packages.length) &&
          packages.every((pkg) =>
            maintainers[pkg]?.includes(pull_request.user.id),
          ),
        '12.approved-by: package-maintainer': packages.some((pkg) =>
          maintainers[pkg]?.some((m) => approvals.has(m)),
        ),
      })

      if (!pull_request.draft) {
        let owners = []
        try {
          // TODO: Create owner map similar to maintainer map.
          owners = (await readFile(`${pull_number}/owners.txt`, 'utf-8')).split(
            '\n',
          )
        } catch (e) {
          // Older artifacts don't have the owners.txt, yet.
          if (e.code !== 'ENOENT') throw e
        }

        // We set this label earlier already, but the current PR state can be very different
        // after handleReviewers has requested reviews, so update it in this case to prevent
        // this label from flip-flopping.
        prLabels['9.needs: reviewer'] = await handleReviewers({
          github,
          context,
          core,
          log,
          dry,
          pull_request,
          reviews,
          // TODO: Use maintainer map instead of the artifact.
          maintainers: Object.keys(
            JSON.parse(
              await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
            ),
          ).map((id) => parseInt(id)),
          owners,
          getTeamMembers,
          getUser,
        })
      }
    }

    return prLabels
  }

  // Returns true if the issue was closed. In this case, the labeling does not need to
  // continue for this issue. Returns false if no action was taken.
  async function handleAutoClose(item) {
    const issue_number = item.number

    if (item.labels.some(({ name }) => name === '0.kind: packaging request')) {
      const body = [
        'Thank you for your interest in packaging new software in Nixpkgs. Unfortunately, to mitigate the unsustainable growth of unmaintained packages, **Nixpkgs is no longer accepting package requests** via Issues.',
        '',
        'As a [volunteer community][community], we are always open to new contributors. If you wish to see this package in Nixpkgs, **we encourage you to [contribute] it yourself**, via a Pull Request. Anyone can [become a package maintainer][maintainers]! You can find language-specific packaging information in the [Nixpkgs Manual][nixpkgs]. Should you need any help, please reach out to the community on [Matrix] or [Discourse].',
        '',
        '[community]: https://nixos.org/community',
        '[contribute]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md#quick-start-to-adding-a-package',
        '[maintainers]: https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md',
        '[nixpkgs]: https://nixos.org/manual/nixpkgs/unstable/',
        '[Matrix]: https://matrix.to/#/#dev:nixos.org',
        '[Discourse]: https://discourse.nixos.org/c/dev/14',
      ].join('\n')

      core.info(`Issue #${item.number}: auto-closed`)

      if (!dry) {
        await github.rest.issues.createComment({
          ...context.repo,
          issue_number,
          body,
        })

        await github.rest.issues.update({
          ...context.repo,
          issue_number,
          state: 'closed',
          state_reason: 'not_planned',
        })
      }

      return true
    }
    return false
  }

  async function handle({ item, stats }) {
    try {
      const log = (k, v, skip) => {
        core.info(`#${item.number} - ${k}: ${v}${skip ? ' (skipped)' : ''}`)
        return skip
      }

      log('Last updated at', item.updated_at)
      log('URL', item.html_url)

      const issue_number = item.number

      const itemLabels = {}

      const events = await github.paginate(
        github.rest.issues.listEventsForTimeline,
        {
          ...context.repo,
          issue_number,
          per_page: 100,
        },
      )

      const latest_event_at = new Date(
        events
          .filter(({ event }) =>
            [
              // These events are hand-picked from:
              //   https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28
              // Each of those causes a PR/issue to *not* be considered as stale anymore.
              // Most of these use created_at.
              'assigned',
              'commented', // uses updated_at, because that could be > created_at
              'committed', // uses committer.date
              ...(item.labels.some(({ name }) => name === '5.scope: tracking')
                ? ['cross-referenced']
                : []),
              'head_ref_force_pushed',
              'milestoned',
              'pinned',
              'ready_for_review',
              'renamed',
              'reopened',
              'review_dismissed',
              'review_requested',
              'reviewed', // uses submitted_at
              'unlocked',
              'unmarked_as_duplicate',
            ].includes(event),
          )
          .map(
            ({ created_at, updated_at, committer, submitted_at }) =>
              new Date(
                updated_at ?? created_at ?? submitted_at ?? committer.date,
              ),
          )
          // Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates.
          .sort((a, b) => b - a)
          .at(0) ?? item.created_at,
      )
      log('latest_event_at', latest_event_at.toISOString())

      const stale_at = new Date(new Date().setDate(new Date().getDate() - 180))
      const is_stale = latest_event_at < stale_at

      if (item.pull_request || context.payload.pull_request) {
        // No need to compute merge commits for stale PRs over and over again.
        // This increases the repo size on GitHub's side unnecessarily and wastes
        // a lot of API requests, too. Any relevant change will result in the
        // stale status to change and thus pick up the PR again for labeling.
        if (!is_stale) {
          stats.prs++
          Object.assign(
            itemLabels,
            await handlePullRequest({ item, stats, events }),
          )
        }
      } else {
        stats.issues++
        if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) {
          // If this returns true, the issue was closed. In this case we return, to not
          // label the issue anymore. Most importantly this avoids unlabeling stale issues
          // which are closed via auto-close.
          if (await handleAutoClose(item)) return
        }
      }

      // Create a map (Label -> Boolean) of all currently set labels.
      // Each label is set to True and can be disabled later.
      const before = Object.fromEntries(
        (
          await github.paginate(github.rest.issues.listLabelsOnIssue, {
            ...context.repo,
            issue_number,
          })
        ).map(({ name }) => [name, true]),
      )

      Object.assign(itemLabels, {
        '2.status: stale': !before['1.severity: security'] && is_stale,
      })

      const after = Object.assign({}, before, itemLabels)

      // No need for an API request, if all labels are the same.
      const hasChanges = Object.keys(after).some(
        (name) => (before[name] ?? false) !== after[name],
      )
      if (log('Has label changes', hasChanges, !hasChanges)) return

      // Skipping labeling on a pull_request event, because we have no privileges.
      const labels = Object.entries(after)
        .filter(([, value]) => value)
        .map(([name]) => name)
      if (log('Set labels', labels, dry)) return

      await github.rest.issues.setLabels({
        ...context.repo,
        issue_number,
        labels,
      })
    } catch (cause) {
      throw new Error(`Labeling #${item.number} failed.`, { cause })
    }
  }

  // Controls level of parallelism. Applies to both the number of concurrent requests
  // as well as the number of concurrent workers going through the list of PRs.
  // We'll only boost concurrency when we're running many PRs in parallel on a schedule,
  // but not for single PRs. This avoids things going wild, when we accidentally make
  // too many API requests on treewides.
  const maxConcurrent = context.payload.pull_request ? 1 : 20

  await withRateLimit({ github, core, maxConcurrent }, async (stats) => {
    if (context.payload.pull_request) {
      await handle({ item: context.payload.pull_request, stats })
    } else {
      const lastRun = (
        await github.rest.actions.listWorkflowRuns({
          ...context.repo,
          workflow_id: 'bot.yml',
          event: 'schedule',
          status: 'success',
          exclude_pull_requests: true,
          per_page: 1,
        })
      ).data.workflow_runs[0]

      const cutoff = new Date(
        Math.max(
          // Go back as far as the last successful run of this workflow to make sure
          // we are not leaving anyone behind on GHA failures.
          // Defaults to go back 1 hour on the first run.
          new Date(
            lastRun?.created_at ?? Date.now() - 1 * 60 * 60 * 1000,
          ).getTime(),
          // Go back max. 1 day to prevent hitting all API rate limits immediately,
          // when GH API returns a wrong workflow by accident.
          Date.now() - 24 * 60 * 60 * 1000,
        ),
      )
      core.info(`cutoff timestamp: ${cutoff.toISOString()}`)

      const updatedItems = await github.paginate(
        github.rest.search.issuesAndPullRequests,
        {
          q: [
            `repo:"${context.repo.owner}/${context.repo.repo}"`,
            'is:open',
            `updated:>=${cutoff.toISOString()}`,
          ].join(' AND '),
          per_page: 100,
          // TODO: Remove after 2025-11-04, when it becomes the default.
          advanced_search: true,
        },
      )

      let cursor

      // No workflow run available the first time.
      if (lastRun) {
        // The cursor to iterate through the full list of issues and pull requests
        // is passed between jobs as an artifact.
        const artifact = (
          await github.rest.actions.listWorkflowRunArtifacts({
            ...context.repo,
            run_id: lastRun.id,
            name: 'pagination-cursor',
          })
        ).data.artifacts[0]

        // If the artifact is not available, the next iteration starts at the beginning.
        if (artifact && !artifact.expired) {
          stats.artifacts++

          const { downloadPath } = await artifactClient.downloadArtifact(
            artifact.id,
            {
              findBy: {
                repositoryName: context.repo.repo,
                repositoryOwner: context.repo.owner,
                token: core.getInput('github-token'),
              },
              expectedHash: artifact.digest,
            },
          )

          cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8')
        }
      }

      // From GitHub's API docs:
      //   GitHub's REST API considers every pull request an issue, but not every issue is a pull request.
      //   For this reason, "Issues" endpoints may return both issues and pull requests in the response.
      //   You can identify pull requests by the pull_request key.
      const allItems = await github.rest.issues.listForRepo({
        ...context.repo,
        state: 'open',
        sort: 'created',
        direction: 'asc',
        per_page: 100,
        after: cursor,
      })

      // Regex taken and comment adjusted from:
      // https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41
      // `allItems.headers.link` format:
      //   <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next",
      //   <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev"
      // Sets `next` to undefined if "next" URL is not present or `link` header is not set.
      const next = ((allItems.headers.link ?? '').match(
        /<([^<>]+)>;\s*rel="next"/,
      ) ?? [])[1]
      if (next) {
        cursor = new URL(next).searchParams.get('after')
        const uploadPath = path.resolve('cursor')
        await writeFile(uploadPath, cursor, 'utf-8')
        if (dry) {
          core.info(`pagination-cursor: ${cursor} (upload skipped)`)
        } else {
          // No stats.artifacts++, because this does not allow passing a custom token.
          // Thus, the upload will not happen with the app token, but the default github.token.
          await artifactClient.uploadArtifact(
            'pagination-cursor',
            [uploadPath],
            path.resolve('.'),
            {
              retentionDays: 1,
            },
          )
        }
      }

      // Some items might be in both search results, so filtering out duplicates as well.
      const items = []
        .concat(updatedItems, allItems.data)
        .filter(
          (thisItem, idx, arr) =>
            idx ===
            arr.findIndex((firstItem) => firstItem.number === thisItem.number),
        )

      // Instead of handling all items in parallel we set up some workers to handle the queue
      // with more controlled parallelism. This avoids problems with `pull_request` fetched at
      // the beginning getting out of date towards the end, because it took the whole job 20
      // minutes or more to go through 100's of PRs.
      await Promise.all(
        Array.from({ length: maxConcurrent }, async () => {
          while (true) {
            const item = items.pop()
            if (!item) break
            try {
              await handle({ item, stats })
            } catch (e) {
              core.setFailed(`${e.message}\n${e.cause.stack}`)
            }
          }
        }),
      )
    }
  })
}
