name: PocketBase AI Bot

# Natural-language companion to pocketbase-bot.yml.
# Mention the bot in plain English, e.g.:
#   @pocketbase-bot change RAM to 4096 on zigbee2mqtt
#   @pocketbase-bot disable script Nextcloud because upstream is broken
# The bot parses the request with GitHub Models, replies with the exact change(s)
# it understood, and only applies them after you reply "@pocketbase-bot confirm".
# The slash-command bot (/pocketbase ...) is unaffected; triggers do not overlap.

on:
  issue_comment:
    types: [created]

permissions:
  models: read # lets the built-in GITHUB_TOKEN call GitHub Models inference
  contents: write # built-in token opens the CT-defaults sync PR (like the slash bot)
  pull-requests: write

jobs:
  ai-bot:
    runs-on: self-hosted
    # Broad gate; the script does precise keyword + self-author checks.
    if: contains(github.event.comment.body, '@pocketbase-bot')

    steps:
      - name: Mint GitHub App token (bot identity)
        id: app-token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.PB_BOT_APP_ID }}
          private-key: ${{ secrets.PB_BOT_APP_PRIVATE_KEY }}

      - name: Run PocketBase AI bot
        env:
          # GitHub REST as the bot identity
          GH_APP_TOKEN: ${{ steps.app-token.outputs.token }}
          PB_BOT_APP_ID: ${{ secrets.PB_BOT_APP_ID }}
          # GitHub Models inference uses the built-in token (needs models: read)
          MODELS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Built-in token for git/PR ops (CT-defaults sync), mirroring the slash bot
          GH_DEFAULT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          AI_MODEL: openai/gpt-4o
          # PocketBase
          POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }}
          POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }}
          POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }}
          POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
          FRONTEND_URL: ${{ secrets.FRONTEND_URL }}
          REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }}
          # Event context
          COMMENT_BODY: ${{ github.event.comment.body }}
          COMMENT_ID: ${{ github.event.comment.id }}
          COMMENT_AUTHOR_TYPE: ${{ github.event.comment.user.type }}
          ISSUE_NUMBER: ${{ github.event.issue.number }}
          REPO_OWNER: ${{ github.repository_owner }}
          REPO_NAME: ${{ github.event.repository.name }}
          ACTOR: ${{ github.event.comment.user.login }}
          ACTOR_ASSOCIATION: ${{ github.event.comment.author_association }}
        run: |
          node << 'ENDSCRIPT'
          (async function () {
            const https = require('https');
            const http = require('http');
            const url = require('url');

            // ── HTTP helper with redirect following ────────────────────────────
            function request(fullUrl, opts, redirectCount) {
              redirectCount = redirectCount || 0;
              return new Promise(function (resolve, reject) {
                const u = url.parse(fullUrl);
                const isHttps = u.protocol === 'https:';
                const body = opts.body;
                const options = {
                  hostname: u.hostname,
                  port: u.port || (isHttps ? 443 : 80),
                  path: u.path,
                  method: opts.method || 'GET',
                  headers: opts.headers || {}
                };
                if (body) options.headers['Content-Length'] = Buffer.byteLength(body);
                const lib = isHttps ? https : http;
                const req = lib.request(options, function (res) {
                  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
                    if (redirectCount >= 5) return reject(new Error('Too many redirects from ' + fullUrl));
                    const redirectUrl = url.resolve(fullUrl, res.headers.location);
                    res.resume();
                    resolve(request(redirectUrl, opts, redirectCount + 1));
                    return;
                  }
                  let data = '';
                  res.on('data', function (chunk) { data += chunk; });
                  res.on('end', function () {
                    resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data });
                  });
                });
                req.on('error', reject);
                if (body) req.write(body);
                req.end();
              });
            }

            // ── GitHub REST (as the bot app) ───────────────────────────────────
            const owner = process.env.REPO_OWNER;
            const repo = process.env.REPO_NAME;
            const issueNumber = parseInt(process.env.ISSUE_NUMBER, 10);
            const commentId = parseInt(process.env.COMMENT_ID, 10);
            const actor = process.env.ACTOR;

            function ghRequest(path, method, body) {
              const headers = {
                'Authorization': 'Bearer ' + process.env.GH_APP_TOKEN,
                'Accept': 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
                'User-Agent': 'PocketBase-AI-Bot'
              };
              const bodyStr = body ? JSON.stringify(body) : undefined;
              if (bodyStr) headers['Content-Type'] = 'application/json';
              return request('https://api.github.com' + path, { method: method || 'GET', headers, body: bodyStr });
            }

            // Same as ghRequest but authenticated with the built-in GITHUB_TOKEN.
            // Used for the CT-defaults sync branch/PR (the App token lacks contents:write).
            function ghDefault(path, method, body) {
              const headers = {
                'Authorization': 'Bearer ' + process.env.GH_DEFAULT_TOKEN,
                'Accept': 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
                'User-Agent': 'PocketBase-AI-Bot'
              };
              const bodyStr = body ? JSON.stringify(body) : undefined;
              if (bodyStr) headers['Content-Type'] = 'application/json';
              return request('https://api.github.com' + path, { method: method || 'GET', headers, body: bodyStr });
            }

            async function addReaction(content) {
              try {
                await ghRequest('/repos/' + owner + '/' + repo + '/issues/comments/' + commentId + '/reactions', 'POST', { content });
              } catch (e) { console.warn('Could not add reaction:', e.message); }
            }
            async function postComment(text) {
              const res = await ghRequest('/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments', 'POST', { body: text });
              if (!res.ok) console.warn('Could not post comment:', res.body);
              return res.ok ? JSON.parse(res.body) : null;
            }
            async function updateComment(id, text) {
              const res = await ghRequest('/repos/' + owner + '/' + repo + '/issues/comments/' + id, 'PATCH', { body: text });
              if (!res.ok) console.warn('Could not update comment:', res.body);
            }
            async function listIssueComments() {
              const all = [];
              let page = 1;
              while (page <= 10) {
                const res = await ghRequest('/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments?per_page=100&page=' + page);
                if (!res.ok) break;
                const batch = JSON.parse(res.body);
                all.push.apply(all, batch);
                if (batch.length < 100) break;
                page++;
              }
              return all;
            }

            // ── 1. Self-trigger guard (App-token comments DO re-fire this event) ─
            if (process.env.COMMENT_AUTHOR_TYPE === 'Bot') {
              console.log('Comment authored by a bot — skipping to avoid loops.');
              return;
            }

            // ── 2. Permission gate (mirrors the slash bot) ─────────────────────
            const association = process.env.ACTOR_ASSOCIATION;
            if (association !== 'OWNER' && association !== 'MEMBER') {
              await addReaction('-1');
              await postComment(
                '❌ **PocketBase AI Bot**: @' + actor + ' is not authorized to use this command.\n' +
                'Only org members (Contributors team) can use `@pocketbase-bot`.'
              );
              return;
            }

            // ── 3. Extract the instruction after the @pocketbase-bot handle ────
            const commentBody = process.env.COMMENT_BODY || '';
            const handleMatch = commentBody.match(/@pocketbase-bot(\[bot\])?/i);
            if (!handleMatch) {
              console.log('No @pocketbase-bot handle found — ignoring.');
              return;
            }
            const instruction = commentBody.slice(handleMatch.index + handleMatch[0].length).trim();
            if (!instruction) {
              await addReaction('-1');
              await postComment(
                'ℹ️ **PocketBase AI Bot**: Tell me what to do, e.g.\n' +
                '`@pocketbase-bot change RAM to 4096 on zigbee2mqtt` or `@pocketbase-bot disable script Nextcloud`.'
              );
              return;
            }

            // ── PocketBase auth + low-level helpers ────────────────────────────
            const pbRaw = process.env.POCKETBASE_URL.replace(/\/$/, '');
            const apiBase = /\/api$/i.test(pbRaw) ? pbRaw : pbRaw + '/api';
            const coll = process.env.POCKETBASE_COLLECTION;
            const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';

            async function pbAuth() {
              const res = await request(apiBase + '/collections/users/auth-with-password', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ identity: process.env.POCKETBASE_ADMIN_EMAIL, password: process.env.POCKETBASE_ADMIN_PASSWORD })
              });
              if (!res.ok) throw new Error('PocketBase auth failed: ' + res.body);
              return JSON.parse(res.body).token;
            }
            async function pbFindRecord(token, slug) {
              const filter = "(slug='" + String(slug).replace(/'/g, "''") + "')";
              const res = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', { headers: { 'Authorization': token } });
              const list = JSON.parse(res.body);
              return list.items && list.items[0];
            }
            async function pbPatch(token, id, payload) {
              return request(recordsUrl + '/' + id, {
                method: 'PATCH',
                headers: { 'Authorization': token, 'Content-Type': 'application/json' },
                body: JSON.stringify(payload)
              });
            }
            function readJsonBlob(val) {
              if (Array.isArray(val)) return val;
              try { return JSON.parse(val || '[]'); } catch (e) { return []; }
            }
            async function revalidate(s) {
              const frontendUrl = process.env.FRONTEND_URL;
              const secret = process.env.REVALIDATE_SECRET;
              if (!frontendUrl || !secret) return;
              try {
                await request(frontendUrl.replace(/\/$/, '') + '/api/revalidate', {
                  method: 'POST',
                  headers: { 'Authorization': 'Bearer ' + secret, 'Content-Type': 'application/json' },
                  body: JSON.stringify({ tags: ['scripts', 'script-' + s] })
                });
              } catch (e) { console.warn('Revalidation skipped:', e.message); }
            }

            // ── CT-defaults sync PR (copied from slash bot) ────────────────────
            function encodeContentPath(filePath) { return filePath.split('/').map(encodeURIComponent).join('/'); }
            function decodeGitHubContent(content) { return Buffer.from((content || '').replace(/\n/g, ''), 'base64').toString('utf8'); }
            function sanitizeBranchPart(value) {
              return (value || '').toLowerCase().replace(/[^a-z0-9._/-]+/g, '-').replace(/\/+/g, '/').replace(/^-+|-+$/g, '');
            }
            function applyCtDefaultChanges(scriptText, varChanges) {
              let nextText = scriptText;
              const updatedVars = [], unchangedVars = [];
              for (const [varName, rawValue] of Object.entries(varChanges)) {
                const newValue = String(rawValue);
                const pattern = new RegExp('(^\\s*' + varName + '="\\$\\{' + varName + ':-)([^"}]*)(\\}"\\s*$)', 'm');
                const match = nextText.match(pattern);
                if (!match) continue;
                if (match[2] === newValue) { unchangedVars.push(varName); continue; }
                nextText = nextText.replace(pattern, '$1' + newValue + '$3');
                updatedVars.push(varName);
              }
              return { nextText, updatedVars, unchangedVars };
            }
            async function ensureBranch(defaultBranch, branchName) {
              const ghRequest = ghDefault; // git ops run as the built-in token
              const branchRefRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/ref/heads/' + encodeURIComponent(branchName));
              if (branchRefRes.ok) return;
              const defaultRefRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/ref/heads/' + encodeURIComponent(defaultBranch));
              if (!defaultRefRes.ok) throw new Error('Could not read default branch ref: ' + defaultRefRes.body);
              const defaultRef = JSON.parse(defaultRefRes.body);
              const createBranchRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/refs', 'POST', { ref: 'refs/heads/' + branchName, sha: defaultRef.object.sha });
              if (!createBranchRes.ok) throw new Error('Could not create branch: ' + createBranchRes.body);
            }
            async function upsertCtDefaultsPr(slugValue, varChanges) {
              const ghRequest = ghDefault; // contents/PR ops run as the built-in token
              const wantedEntries = Object.entries(varChanges || {}).filter(function ([, v]) { return v !== undefined && v !== null && String(v) !== ''; });
              if (wantedEntries.length === 0) return { status: 'skipped', reason: 'No mapped CT defaults changed.' };
              const repoRes = await ghRequest('/repos/' + owner + '/' + repo);
              if (!repoRes.ok) throw new Error('Could not read repository metadata: ' + repoRes.body);
              const defaultBranch = JSON.parse(repoRes.body).default_branch;
              const ctPath = 'ct/' + slugValue + '.sh';
              const encodedCtPath = encodeContentPath(ctPath);
              const defaultFileRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath + '?ref=' + encodeURIComponent(defaultBranch));
              if (defaultFileRes.statusCode === 404) return { status: 'skipped', reason: 'No matching CT file found at `' + ctPath + '`.' };
              if (!defaultFileRes.ok) throw new Error('Could not read CT file from default branch: ' + defaultFileRes.body);
              const branchName = 'pocketbase-sync/' + sanitizeBranchPart(slugValue || 'unknown');
              await ensureBranch(defaultBranch, branchName);
              const branchFileRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath + '?ref=' + encodeURIComponent(branchName));
              if (!branchFileRes.ok) throw new Error('Could not read CT file from sync branch: ' + branchFileRes.body);
              const branchFile = JSON.parse(branchFileRes.body);
              const currentBranchText = decodeGitHubContent(branchFile.content);
              const updateResult = applyCtDefaultChanges(currentBranchText, Object.fromEntries(wantedEntries));
              if (updateResult.updatedVars.length === 0) return { status: 'skipped', reason: 'CT defaults already up to date.' };
              const putRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath, 'PUT', {
                message: 'chore(ct): sync ' + slugValue + ' defaults from PocketBase',
                content: Buffer.from(updateResult.nextText, 'utf8').toString('base64'),
                sha: branchFile.sha,
                branch: branchName
              });
              if (!putRes.ok) throw new Error('Could not update CT file: ' + putRes.body);
              const openPrRes = await ghRequest('/repos/' + owner + '/' + repo + '/pulls?state=open&head=' + encodeURIComponent(owner + ':' + branchName) + '&base=' + encodeURIComponent(defaultBranch));
              if (!openPrRes.ok) throw new Error('Could not query existing PRs: ' + openPrRes.body);
              const openPrs = JSON.parse(openPrRes.body);
              if (openPrs.length > 0) return { status: 'updated', prUrl: openPrs[0].html_url, updatedVars: updateResult.updatedVars };
              const createPrRes = await ghRequest('/repos/' + owner + '/' + repo + '/pulls', 'POST', {
                title: 'chore(ct): sync ' + slugValue + ' defaults with PocketBase',
                body: '## Summary\n- Sync default CT variables for `' + slugValue + '` after an `@pocketbase-bot` update.\n- Updated vars: `' + updateResult.updatedVars.join('`, `') + '`.\n\n## Source\n- Triggered by @' + actor + ' via PocketBase AI bot.\n',
                head: branchName,
                base: defaultBranch
              });
              if (!createPrRes.ok) throw new Error('Could not create PR: ' + createPrRes.body);
              return { status: 'created', prUrl: JSON.parse(createPrRes.body).html_url, updatedVars: updateResult.updatedVars };
            }

            // ── Allow-lists (mirror the slash bot) ─────────────────────────────
            const ALLOWED_FIELDS = {
              name: 'string', description: 'string', logo: 'string', documentation: 'string',
              website: 'string', project_url: 'string', github: 'string', config_path: 'string',
              tags: 'string', port: 'number', default_user: 'nullable_string', default_passwd: 'nullable_string',
              unprivileged: 'number', updateable: 'boolean', privileged: 'boolean', has_arm: 'boolean',
              is_dev: 'boolean', is_disabled: 'boolean', disable_message: 'string',
              is_deleted: 'boolean', deleted_message: 'string'
            };
            const FIELD_TO_CT_VAR = { tags: 'var_tags', unprivileged: 'var_unprivileged' };
            const RESOURCE_KEYS = { cpu: 'number', ram: 'number', hdd: 'number', os: 'string', version: 'string' };
            const METHOD_KEYS = { config_path: 'string', script: 'string' };
            const ALL_METHOD_KEYS = Object.assign({}, RESOURCE_KEYS, METHOD_KEYS);
            const RESOURCE_TO_CT_VAR = { cpu: 'var_cpu', ram: 'var_ram', hdd: 'var_disk', os: 'var_os', version: 'var_version' };

            function castFieldValue(key, rawVal) {
              const type = ALLOWED_FIELDS[key];
              if (!type) return { error: 'Unknown field `' + key + '`' };
              if (type === 'boolean') {
                if (rawVal === true || rawVal === 'true') return { value: true };
                if (rawVal === false || rawVal === 'false') return { value: false };
                return { error: '`' + key + '` must be true/false' };
              }
              if (type === 'number') {
                const n = parseInt(rawVal, 10);
                if (isNaN(n)) return { error: '`' + key + '` must be a number' };
                return { value: n };
              }
              if (type === 'nullable_string') return { value: rawVal === '' || rawVal == null ? null : String(rawVal) };
              return { value: String(rawVal) };
            }

            // ── Operation validation (used at propose AND confirm time) ────────
            // Never trust raw operations: enforce the field/op allow-lists and
            // re-cast values. Returns only well-formed, allowed operations.
            function sanitizeOperations(ops) {
              const validOps = [], problems = [];
              for (const op of (Array.isArray(ops) ? ops : [])) {
                if (op && op.kind === 'field') {
                  const cast = castFieldValue(op.field, op.value);
                  if (cast.error) { problems.push(cast.error); continue; }
                  validOps.push({ kind: 'field', field: op.field, value: cast.value });
                } else if (op && op.kind === 'note' && ['add', 'edit', 'remove'].includes(op.action)) {
                  validOps.push({ kind: 'note', action: op.action, type: String(op.type || ''), text: op.text, newText: op.newText });
                } else if (op && op.kind === 'method' && ['add', 'edit', 'remove'].includes(op.action)) {
                  const changes = {};
                  for (const [k, v] of Object.entries(op.changes || {})) { if (ALL_METHOD_KEYS[k]) changes[k] = v; }
                  validOps.push({ kind: 'method', action: op.action, type: String(op.type || 'default'), changes });
                } else {
                  problems.push('Unsupported operation: `' + JSON.stringify(op) + '`');
                }
              }
              return { validOps, problems };
            }

            // ── Executor: apply a validated {slug, operations} set ─────────────
            async function applyOperations(action) {
              const token = await pbAuth();
              const record = await pbFindRecord(token, action.slug);
              if (!record) return { ok: false, summary: '❌ No PocketBase record for slug `' + action.slug + '`.' };

              const fieldPayload = {};
              let notesArr = readJsonBlob(record.notes);
              let methodsArr = readJsonBlob(record.install_methods);
              let notesChanged = false, methodsChanged = false;
              const ctChanges = {};
              const lines = [];

              for (const op of action.operations) {
                if (op.kind === 'field') {
                  const cast = castFieldValue(op.field, op.value);
                  if (cast.error) { lines.push('- ⚠️ skipped field: ' + cast.error); continue; }
                  fieldPayload[op.field] = cast.value;
                  if (FIELD_TO_CT_VAR[op.field]) ctChanges[FIELD_TO_CT_VAR[op.field]] = cast.value;
                  lines.push('- `' + op.field + '` → `' + JSON.stringify(cast.value) + '`');
                } else if (op.kind === 'note') {
                  const type = String(op.type || '').toLowerCase();
                  if (op.action === 'add') {
                    notesArr.push({ type, text: String(op.text || '') });
                    notesChanged = true; lines.push('- note add `' + type + '`: ' + op.text);
                  } else if (op.action === 'remove') {
                    const before = notesArr.length;
                    notesArr = notesArr.filter(function (n) { return !(String(n.type).toLowerCase() === type && n.text === op.text); });
                    if (notesArr.length !== before) { notesChanged = true; lines.push('- note remove `' + type + '`: ' + op.text); }
                    else lines.push('- ⚠️ note remove: no `' + type + '` note matched');
                  } else if (op.action === 'edit') {
                    const idx = notesArr.findIndex(function (n) { return String(n.type).toLowerCase() === type && n.text === op.text; });
                    if (idx !== -1) { notesArr[idx].text = String(op.newText || ''); notesChanged = true; lines.push('- note edit `' + type + '`'); }
                    else lines.push('- ⚠️ note edit: no `' + type + '` note matched');
                  }
                } else if (op.kind === 'method') {
                  const type = String(op.type || '').toLowerCase();
                  const changes = op.changes || {};
                  if (op.action === 'remove') {
                    const before = methodsArr.length;
                    methodsArr = methodsArr.filter(function (im) { return String(im.type || '').toLowerCase() !== type; });
                    if (methodsArr.length !== before) { methodsChanged = true; lines.push('- method remove `' + type + '`'); }
                    else lines.push('- ⚠️ method remove: `' + type + '` not found');
                  } else {
                    let method = methodsArr.find(function (im) { return String(im.type || '').toLowerCase() === type; });
                    if (!method && op.action === 'add') { method = { type, resources: { cpu: 1, ram: 512, hdd: 4, os: 'debian', version: '13' } }; methodsArr.push(method); }
                    if (!method) { lines.push('- ⚠️ method edit: `' + type + '` not found'); continue; }
                    if (!method.resources) method.resources = {};
                    for (const [k, v] of Object.entries(changes)) {
                      if (RESOURCE_KEYS[k]) {
                        method.resources[k] = RESOURCE_KEYS[k] === 'number' ? parseInt(v, 10) : String(v);
                        if (RESOURCE_TO_CT_VAR[k]) ctChanges[RESOURCE_TO_CT_VAR[k]] = method.resources[k];
                      } else if (METHOD_KEYS[k]) {
                        method[k] = v === '' ? null : String(v);
                      }
                    }
                    methodsChanged = true;
                    lines.push('- method `' + (op.action === 'add' ? 'add' : 'edit') + '` `' + type + '`: ' + JSON.stringify(changes));
                  }
                }
              }

              if (Object.keys(fieldPayload).length) {
                const r = await pbPatch(token, record.id, fieldPayload);
                if (!r.ok) return { ok: false, summary: '❌ Field update failed:\n```\n' + r.body + '\n```' };
              }
              if (notesChanged) {
                const r = await pbPatch(token, record.id, { notes: notesArr });
                if (!r.ok) return { ok: false, summary: '❌ Notes update failed:\n```\n' + r.body + '\n```' };
              }
              if (methodsChanged) {
                const r = await pbPatch(token, record.id, { install_methods: methodsArr });
                if (!r.ok) return { ok: false, summary: '❌ Install-method update failed:\n```\n' + r.body + '\n```' };
              }
              await revalidate(action.slug);

              let ctNote = '';
              if (Object.keys(ctChanges).length) {
                try {
                  const sync = await upsertCtDefaultsPr(action.slug, ctChanges);
                  if (sync.status === 'created') ctNote = '\n\n**CT sync PR:** ' + sync.prUrl;
                  else if (sync.status === 'updated') ctNote = '\n\n**CT sync PR updated:** ' + sync.prUrl;
                  else if (sync.status === 'skipped') ctNote = '\n\n**CT sync skipped:** ' + sync.reason;
                } catch (e) { ctNote = '\n\n**CT sync failed:** ' + e.message; }
              }
              return { ok: true, summary: lines.join('\n') + ctNote };
            }

            // ── 4. Confirm branch ──────────────────────────────────────────────
            const PENDING_RE = /<!--\s*pocketbase-pending:\s*([A-Za-z0-9+/=]+)\s*-->/;
            const isConfirm = /^(confirm|yes|apply|do it|y)\b/i.test(instruction);

            if (isConfirm) {
              const comments = await listIssueComments();
              const appId = String(process.env.PB_BOT_APP_ID || '');
              let pending = null, pendingComment = null;
              for (let i = comments.length - 1; i >= 0; i--) {
                const c = comments[i];
                // Only trust a marker in a comment THIS bot app authored — otherwise a
                // user could hand-craft a forged pocketbase-pending marker and confirm it.
                const byBotApp = c.user && c.user.type === 'Bot' &&
                  c.performed_via_github_app && String(c.performed_via_github_app.id) === appId;
                if (!byBotApp) continue;
                const m = c.body && c.body.match(PENDING_RE);
                if (m) { pending = m[1]; pendingComment = c; break; }
              }
              if (!pending) {
                await addReaction('confused');
                await postComment('🤔 **PocketBase AI Bot**: I have no pending change to confirm in this thread.');
                return;
              }
              let action;
              try { action = JSON.parse(Buffer.from(pending, 'base64').toString('utf8')); }
              catch (e) { await postComment('❌ **PocketBase AI Bot**: Could not decode the pending change.'); return; }

              // Re-validate the decoded operations before applying (defense-in-depth).
              const recheck = sanitizeOperations(action.operations);
              if (!action.slug || recheck.validOps.length === 0) {
                await addReaction('-1');
                await postComment('❌ **PocketBase AI Bot**: The pending change is no longer valid. Please restate the request.');
                return;
              }
              action.operations = recheck.validOps;

              let result;
              try { result = await applyOperations(action); }
              catch (e) { await addReaction('-1'); await postComment('❌ **PocketBase AI Bot**: ' + e.message); return; }

              if (!result.ok) { await addReaction('-1'); await postComment(result.summary); return; }
              await updateComment(pendingComment.id, pendingComment.body.replace(PENDING_RE, '<!-- pocketbase-applied -->'));
              await addReaction('+1');
              await postComment(
                '✅ **PocketBase AI Bot**: Applied to **`' + action.slug + '`**\n\n' + result.summary +
                '\n\n*Confirmed by @' + actor + '*'
              );
              return;
            }

            // ── 5. New request: acknowledge, fetch script list, call the model ─
            await addReaction('eyes');

            const token0 = await pbAuth();
            const scripts = [];
            let page = 1;
            while (page <= 5) {
              const res = await request(recordsUrl + '?fields=slug,name&perPage=500&page=' + page, { headers: { 'Authorization': token0 } });
              if (!res.ok) break;
              const data = JSON.parse(res.body);
              (data.items || []).forEach(function (it) { if (it.slug) scripts.push({ slug: it.slug, name: it.name || it.slug }); });
              if (!data.items || data.items.length < 500) break;
              page++;
            }

            const SYSTEM_PROMPT =
              'You translate a maintainer\'s natural-language request into a STRICT JSON change-set for a ' +
              'script catalog (PocketBase). Respond with a SINGLE JSON object and nothing else.\n\n' +
              'Schema:\n' +
              '{\n' +
              '  "slug": string|null,            // MUST be one of the known slugs below, chosen from the request\n' +
              '  "operations": [ ... ],          // [] if you cannot determine concrete changes\n' +
              '  "human_summary": string,        // short plain-English description\n' +
              '  "clarification": string|null    // a question to ask if ambiguous/unsupported; else null\n' +
              '}\n\n' +
              'Operation kinds:\n' +
              '- {"kind":"field","field":<one of: ' + Object.keys(ALLOWED_FIELDS).join(', ') + '>,"value":<bool|number|string>}\n' +
              '  (booleans true/false; "is_disabled"/"is_deleted"/"is_dev" are booleans; "port"/"unprivileged" are numbers; rest strings.)\n' +
              '- {"kind":"note","action":"add"|"edit"|"remove","type":string,"text":string,"newText":string?}\n' +
              '- {"kind":"method","action":"add"|"edit"|"remove","type":string,"changes":{cpu?:number,ram?:number,hdd?:number,os?:string,version?:string,config_path?:string,script?:string}}\n' +
              '  (RAM/HDD are in MB/GB; method "type" defaults to "default" if the user does not name one.)\n\n' +
              'Rules:\n' +
              '- Only use fields/operations listed above. If the request needs something else, set clarification and operations=[].\n' +
              '- "disable"/"enable" map to is_disabled true/false. If disabling and the user gave a reason, also set disable_message.\n' +
              '- Resolve the target script to a slug from the list. If you cannot confidently match exactly one, set slug=null and ask via clarification.\n\n' +
              'Known scripts (slug — name):\n' +
              scripts.map(function (s) { return s.slug + ' — ' + s.name; }).join('\n');

            const modelRes = await request('https://models.github.ai/inference/chat/completions', {
              method: 'POST',
              headers: { 'Authorization': 'Bearer ' + process.env.MODELS_TOKEN, 'Content-Type': 'application/json', 'Accept': 'application/json' },
              body: JSON.stringify({
                model: process.env.AI_MODEL || 'openai/gpt-4o',
                temperature: 0.1,
                response_format: { type: 'json_object' },
                messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: instruction }]
              })
            });
            if (!modelRes.ok) {
              await addReaction('-1');
              await postComment('❌ **PocketBase AI Bot**: Model request failed (' + modelRes.statusCode + ').\n```\n' + modelRes.body.slice(0, 500) + '\n```');
              return;
            }

            let parsed;
            try {
              const content = JSON.parse(modelRes.body).choices[0].message.content;
              const cleaned = content.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
              parsed = JSON.parse(cleaned);
            } catch (e) {
              await addReaction('-1');
              await postComment('❌ **PocketBase AI Bot**: Could not parse the model response. Please rephrase.');
              return;
            }

            // ── 6. Validate ────────────────────────────────────────────────────
            const knownSlugs = new Set(scripts.map(function (s) { return s.slug; }));
            const problems = [];
            if (parsed.clarification) problems.push(parsed.clarification);
            if (!parsed.slug || !knownSlugs.has(parsed.slug)) problems.push('I could not match the request to a known script.');
            const sanitized = sanitizeOperations(parsed.operations);
            const validOps = sanitized.validOps;
            problems.push.apply(problems, sanitized.problems);
            if (validOps.length === 0) problems.push('No concrete, supported change was found.');

            if (problems.length) {
              await addReaction('confused');
              await postComment(
                '🤔 **PocketBase AI Bot**: I need a bit more to act on that.\n\n- ' + problems.join('\n- ') +
                '\n\nTry naming the script and the exact change, e.g. `@pocketbase-bot set RAM to 4096 on zigbee2mqtt`.'
              );
              return;
            }

            // ── 7. Propose (do NOT apply yet) ──────────────────────────────────
            const action = { slug: parsed.slug, operations: validOps };
            const bullets = validOps.map(function (op) {
              if (op.kind === 'field') return '- `' + op.field + '` → `' + JSON.stringify(op.value) + '`';
              if (op.kind === 'note') return '- note ' + op.action + ' `' + op.type + '`' + (op.text ? ': ' + op.text : '');
              return '- method ' + op.action + ' `' + op.type + '`: ' + JSON.stringify(op.changes);
            }).join('\n');
            const marker = '<!-- pocketbase-pending: ' + Buffer.from(JSON.stringify(action), 'utf8').toString('base64') + ' -->';
            await addReaction('+1');
            await postComment(
              '🤖 **PocketBase AI Bot** — please confirm\n\n' +
              (parsed.human_summary ? '> ' + parsed.human_summary + '\n\n' : '') +
              '**Target:** `' + action.slug + '`\n**Proposed changes:**\n' + bullets + '\n\n' +
              'Reply **`@pocketbase-bot confirm`** to apply, or restate the request to adjust.\n' + marker
            );
          })().catch(function (e) {
            console.error('Fatal error:', e && (e.message || e));
            process.exit(1);
          });
          ENDSCRIPT
