name: Issue Topic Labeler

on:
  issues:
    types:
      - opened

jobs:
  label-topic:
    if: ${{ !github.event.issue.pull_request }}
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: read
      issues: write
    steps:
      - name: Close outdated bug reports
        id: version-check
        uses: actions/github-script@v7
        with:
          script: |
            const issue = context.payload.issue
            const body = issue.body || ''

            if (!issue.title.startsWith('[Bug]')) {
              core.setOutput('outdated', 'false')
              return
            }

            // Prefer the version from the app-generated error report. Fall back
            // to the version entered in the issue form when no report is present.
            const reportMatch = body.match(/^\*\s+__Version:__\s+([^\r\n]+)$/m)
            const formMatch = body.match(/^### Version\s*\r?\n+\s*([^\r\n]+)$/m)
            const reportedRaw = (reportMatch?.[1] || formMatch?.[1] || '').trim()

            const parseVersion = (value) => {
              const match = value.trim().match(
                /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
              )

              if (!match) {
                return null
              }

              return {
                major: Number(match[1]),
                minor: Number(match[2]),
                patch: Number(match[3]),
                prerelease: match[4] || null,
              }
            }

            const compareToRelease = (reported, latest) => {
              for (const part of ['major', 'minor', 'patch']) {
                if (reported[part] < latest[part]) {
                  return -1
                }
                if (reported[part] > latest[part]) {
                  return 1
                }
              }

              // A prerelease is older than the stable release with the same
              // major, minor, and patch numbers.
              if (reported.prerelease && !latest.prerelease) {
                return -1
              }

              return 0
            }

            if (!reportedRaw) {
              core.warning('No version was found; skipping the version check.')
              core.setOutput('outdated', 'false')
              return
            }

            const { owner, repo } = context.repo
            const { data: latestRelease } = await github.rest.repos.getLatestRelease({
              owner,
              repo,
            })
            const reportedVersion = parseVersion(reportedRaw)
            const latestVersion = parseVersion(latestRelease.tag_name)

            if (!reportedVersion || !latestVersion) {
              core.warning(`Could not parse version "${reportedRaw}"; skipping the version check.`)
              core.setOutput('outdated', 'false')
              return
            }

            const outdated = compareToRelease(reportedVersion, latestVersion) < 0
            core.setOutput('outdated', String(outdated))

            if (!outdated) {
              return
            }

            await github.rest.issues.createComment({
              owner,
              repo,
              issue_number: issue.number,
              body: [
                `This report was submitted using PipePipe **${reportedRaw}**, which is older than the latest release, **${latestRelease.tag_name}**.`,
                '',
                `Please [update to the latest release](${latestRelease.html_url}), verify that the issue still occurs, and submit a new bug report if necessary.`,
                '',
                'This issue has been closed automatically.',
              ].join('\n'),
            })

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

      - name: Classify and label issue
        if: steps.version-check.outputs.outdated != 'true'
        uses: actions/github-script@v7
        env:
          ISSUE_LABELER_API_KEY: ${{ secrets.ISSUE_LABELER_API_KEY }}
          ISSUE_LABELER_BASE_URL: ${{ vars.ISSUE_LABELER_BASE_URL }}
          ISSUE_LABELER_MODEL: ${{ vars.ISSUE_LABELER_MODEL }}
          ISSUE_LABELER_PROVIDER: ${{ vars.ISSUE_LABELER_PROVIDER }}
        with:
          script: |
            const allowedLabels = [
              'topic: UI',
              'topic:app',
              'topic: player',
              'topic: Extractor',
              'topic: filter',
              'topic: Download',
              'topic: gesture',
              'spam',
              'topic: others',
            ]

            const labelConfig = {
              'topic: UI': {
                color: '1d76db',
                description: 'User interface and visual behavior issues',
              },
              'topic:app': {
                color: '5319e7',
                description: 'General app-level behavior and settings',
              },
              'topic: player': {
                color: '0052cc',
                description: 'Playback, controls, subtitles, and media behavior',
              },
              'topic: Extractor': {
                color: 'fbca04',
                description: 'Service extraction, parsing, and metadata retrieval',
              },
              'topic: filter': {
                color: '0e8a16',
                description: 'Filtering, sorting, and feed/search refinement',
              },
              'topic: Download': {
                color: '006b75',
                description: 'Downloads, offline saving, and file fetching',
              },
              'topic: gesture': {
                color: 'c5def5',
                description: 'Touch gestures such as swipe, tap, and long-press',
              },
              spam: {
                color: 'b60205',
                description: 'Spam, ads, nonsense, personal attacks, insults, or irrelevant content',
              },
              'topic: others': {
                color: '6a737d',
                description: 'Issues that do not clearly fit another topic',
              },
            }

            const issue = context.payload.issue
            const existingTopicLabel = issue.labels
              .map((label) => typeof label === 'string' ? label : label.name)
              .find((label) => allowedLabels.includes(label))

            if (existingTopicLabel) {
              core.info(`Issue already has topic label: ${existingTopicLabel}`)
              return
            }

            const providerInput = (process.env.ISSUE_LABELER_PROVIDER || '').trim().toLowerCase()
            const baseUrlInput = (process.env.ISSUE_LABELER_BASE_URL || '').trim()
            const apiKey = (process.env.ISSUE_LABELER_API_KEY || '').trim()

            if (!apiKey) {
              throw new Error('Missing ISSUE_LABELER_API_KEY secret.')
            }

            const provider = providerInput || (baseUrlInput ? 'openai-compatible' : 'github-models')

            if (!['github-models', 'openai-compatible'].includes(provider)) {
              throw new Error(`Unsupported ISSUE_LABELER_PROVIDER: ${provider}`)
            }

            const model = (process.env.ISSUE_LABELER_MODEL || '').trim()
              || (provider === 'github-models' ? 'openai/gpt-5-mini' : 'gpt-5-mini')

            const clip = (value, limit) => {
              if (!value || value.length <= limit) {
                return value
              }

              return `${value.slice(0, limit)}\n\n[truncated]`
            }

            const issueText = [
              `Title: ${issue.title || ''}`,
              `Body:\n${issue.body || ''}`,
            ].join('\n\n')

            const systemPrompt = [
              'You classify GitHub issues for an Android media app project.',
              'Choose exactly one label from this list:',
              allowedLabels.join(', '),
              'Guidelines:',
              '- topic: UI => layout, theme, rendering, icons, screens, navigation, visual glitches.',
              '- topic:app => crashes, startup, settings, notifications, intents, backup, import/export, app-level behavior.',
              '- topic: player => playback, subtitles, queue, controls, audio/video, fullscreen, speed, PiP.',
              '- topic: Extractor => site/service parsing, metadata, stream URLs, login/captcha extraction, service-specific fetching.',
              '- topic: filter => local blocking.',
              '- topic: Download => downloads, offline saving, download queue, download files, storage of downloaded media.',
              '- topic: gesture => swipe, tap, long press, touch gestures, gesture controls.',
              '- spam => spam, ads, nonsense/gibberish, personal attacks, insults, harassment, hate speech, trolling, completely irrelevant content.',
              '- topic: others => unclear or not covered above.',
              'Return only the exact label text and nothing else.',
            ].join('\n')

            const requestBody = {
              model,
              messages: [
                { role: 'system', content: systemPrompt },
                { role: 'user', content: clip(issueText, 6000) },
              ],
            }

            const request = provider === 'github-models'
              ? {
                  url: 'https://models.github.ai/inference/chat/completions',
                  headers: {
                    Authorization: `Bearer ${apiKey}`,
                    Accept: 'application/vnd.github+json',
                    'Content-Type': 'application/json',
                    'X-GitHub-Api-Version': '2026-03-10',
                  },
                }
              : {
                  url: `${baseUrlInput.replace(/\/$/, '')}/chat/completions`,
                  headers: {
                    Authorization: `Bearer ${apiKey}`,
                    'Content-Type': 'application/json',
                  },
                }

            if (provider === 'openai-compatible' && !baseUrlInput) {
              throw new Error('ISSUE_LABELER_BASE_URL is required for openai-compatible provider.')
            }

            const response = await fetch(request.url, {
              method: 'POST',
              headers: request.headers,
              body: JSON.stringify(requestBody),
            })

            const rawResponse = await response.text()

            if (!response.ok) {
              throw new Error(`Model request failed (${response.status}): ${rawResponse}`)
            }

            let responseData
            try {
              responseData = JSON.parse(rawResponse)
            } catch (error) {
              throw new Error(`Could not parse model response JSON: ${rawResponse}`)
            }

            const messageContent = responseData?.choices?.[0]?.message?.content
            const content = Array.isArray(messageContent)
              ? messageContent.map((part) => part?.text || '').join('')
              : String(messageContent || '')

            const normalizeLabel = (value) => {
              const trimmed = value.trim()
              if (allowedLabels.includes(trimmed)) {
                return trimmed
              }

              const lowered = trimmed.toLowerCase().replace('topic: app', 'topic:app')
              return allowedLabels.find((label) => label.toLowerCase() === lowered)
                || allowedLabels.find((label) => lowered.includes(label.toLowerCase()))
                || null
            }

            const fallbackLabel = (value) => {
              const text = value.toLowerCase()

              if (/(\bcasino\b|\bpoker\b|\bgamble|\bcrypto\b|\bSEO\b|\bbuy \b|\bfree money\b|\bclick here\b|\bpromotion\b|\badvertisement\b|\btelegram\b|\bwhatsapp\b|\bstupid\b|\bidiot\b|\bmoron\b|\bfuck\b|\bshit\b|\bdumb\b|\basshole\b|\bbastard\b|\bretard\b|\bkill yourself\b|\bkys\b|\bscrew you\b)/.test(text)) {
                return 'spam'
              }

              if (/(swipe|gesture|double tap|long press|touch control|touch gesture|pinch)/.test(text)) {
                return 'topic: gesture'
              }

              if (/(download|offline|save file|save video|save audio|storage permission|download queue)/.test(text)) {
                return 'topic: Download'
              }

              if (/(player|playback|subtitle|captions|fullscreen|picture-in-picture|pip|seek|pause|resume|play video|play audio|speed control)/.test(text)) {
                return 'topic: player'
              }

              if (/(extractor|parsing|parser|metadata|stream url|video url|audio url|service-specific|youtube|bilibili|soundcloud|bandcamp)/.test(text)) {
                return 'topic: Extractor'
              }

              if (/(filter)/.test(text)) {
                return 'topic: filter'
              }

              if (/(theme|layout|screen|button|icon|font|toolbar|bottom bar|navigation|visual|ui | ux |dark mode|light mode)/.test(text)) {
                return 'topic: UI'
              }

              if (/(crash|startup|settings|notification|intent|share menu|backup|restore|import|export|database|login|account)/.test(text)) {
                return 'topic:app'
              }

              return 'topic: others'
            }

            const selectedLabel = normalizeLabel(content) || fallbackLabel(issueText)
            const { color, description } = labelConfig[selectedLabel]

            try {
              await github.rest.issues.getLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                name: selectedLabel,
              })
            } catch (error) {
              if (error.status !== 404) {
                throw error
              }

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

            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: issue.number,
              labels: [selectedLabel],
            })

            if (selectedLabel === 'spam') {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                body: 'This issue has been identified as spam and will be closed.',
              })

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

            core.info(`Model output: ${content}`)
            core.info(`Applied label: ${selectedLabel}`)
            core.setOutput('label', selectedLabel)
