name: PocketBase Bot

on:
  issue_comment:
    types: [created]

permissions:
  issues: write
  pull-requests: write
  contents: write

jobs:
  pocketbase-bot:
    runs-on: self-hosted

    # Only act on /pocketbase commands
    if: startsWith(github.event.comment.body, '/pocketbase')

    steps:
      - name: Execute PocketBase bot command
        env:
          POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }}
          POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }}
          POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }}
          POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
          COMMENT_BODY: ${{ github.event.comment.body }}
          COMMENT_ID: ${{ github.event.comment.id }}
          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 }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          FRONTEND_URL: ${{ secrets.FRONTEND_URL }}
          REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }}
        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 API helpers ─────────────────────────────────────────────
            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.GITHUB_TOKEN,
                'Accept': 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
                'User-Agent': 'PocketBase-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 });
            }

            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 = [];
              const 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 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 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 repoInfo = JSON.parse(repoRes.body);
              const defaultBranch = repoInfo.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.', unchangedVars: updateResult.unchangedVars };
              }

              const commitMessage = 'chore(ct): sync ' + slugValue + ' defaults from PocketBase';
              const putRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath, 'PUT', {
                message: commitMessage,
                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 prTitle = 'chore(ct): sync ' + slugValue + ' defaults with PocketBase';
              const prBody =
                '## Summary\n' +
                '- Sync default CT variables for `' + slugValue + '` after `/pocketbase` update.\n' +
                '- Updated vars: `' + updateResult.updatedVars.join('`, `') + '`.\n\n' +
                '## Source\n' +
                '- Triggered by @' + actor + ' via PocketBase bot.\n';
              const createPrRes = await ghRequest('/repos/' + owner + '/' + repo + '/pulls', 'POST', {
                title: prTitle,
                body: prBody,
                head: branchName,
                base: defaultBranch
              });
              if (!createPrRes.ok) {
                throw new Error('Could not create PR: ' + createPrRes.body);
              }
              const pr = JSON.parse(createPrRes.body);
              return { status: 'created', prUrl: pr.html_url, updatedVars: updateResult.updatedVars };
            }

            function formatCtSyncResult(syncResult) {
              if (!syncResult) return '';
              if (syncResult.status === 'created') return '\n\n**CT sync PR:** ' + syncResult.prUrl;
              if (syncResult.status === 'updated') return '\n\n**CT sync PR updated:** ' + syncResult.prUrl;
              if (syncResult.status === 'skipped') return '\n\n**CT sync skipped:** ' + syncResult.reason;
              return '';
            }

            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);
            }

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

            // ── Acknowledge ────────────────────────────────────────────────────
            await addReaction('eyes');

            // ── Parse command ──────────────────────────────────────────────────
            const commentBody = process.env.COMMENT_BODY || '';
            const lines = commentBody.trim().split('\n');
            const firstLine = lines[0].trim();
            const withoutCmd = firstLine.replace(/^\/pocketbase\s+/, '').trim();

            function extractCodeBlock(body) {
              const m = body.match(/```[^\n]*\n([\s\S]*?)```/);
              return m ? m[1].trim() : null;
            }
            const codeBlockValue = extractCodeBlock(commentBody);

            const HELP_TEXT =
              '**Show current state:**\n' +
              '```\n/pocketbase <slug> info\n```\n\n' +
              '**Field update (simple):** `/pocketbase <slug> field=value [field=value ...]`\n\n' +
              '**Field update (HTML/multiline) — value from code block:**\n' +
              '````\n' +
              '/pocketbase <slug> set description\n' +
              '```html\n' +
              '<p>Your <b>HTML</b> or multi-line content here</p>\n' +
              '```\n' +
              '````\n\n' +
              '**Note management:**\n' +
              '```\n' +
              '/pocketbase <slug> note list\n' +
              '/pocketbase <slug> note add <type> "<text>"\n' +
              '/pocketbase <slug> note edit <type> "<old text>" "<new text>"\n' +
              '/pocketbase <slug> note remove <type> "<text>"\n' +
              '```\n\n' +
              '**Install method management:**\n' +
              '```\n' +
              '/pocketbase <slug> method list\n' +
              '/pocketbase <slug> method <type> cpu=4 ram=2048 hdd=20\n' +
              '/pocketbase <slug> method <type> config_path="/opt/app/.env"\n' +
              '/pocketbase <slug> method <type> os=debian version=13\n' +
              '/pocketbase <slug> method add <type> cpu=2 ram=2048 hdd=8 os=debian version=13\n' +
              '/pocketbase <slug> method remove <type>\n' +
              '```\n' +
              'Method fields: `cpu` `ram` `hdd` `os` `version` `config_path` `script`\n\n' +
              '**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `github` ' +
              '`config_path` `port` `default_user` `default_passwd` ' +
              '`updateable` `privileged` `has_arm` `is_dev` ' +
              '`is_disabled` `disable_message` `is_deleted` `deleted_message`';

            if (!withoutCmd) {
              await addReaction('-1');
              await postComment('❌ **PocketBase Bot**: No slug or command specified.\n\n' + HELP_TEXT);
              process.exit(0);
            }

            const spaceIdx = withoutCmd.indexOf(' ');
            const slug = (spaceIdx === -1 ? withoutCmd : withoutCmd.substring(0, spaceIdx)).trim();
            const rest = spaceIdx === -1 ? '' : withoutCmd.substring(spaceIdx + 1).trim();

            if (!rest) {
              await addReaction('-1');
              await postComment('❌ **PocketBase Bot**: No command specified for slug `' + slug + '`.\n\n' + HELP_TEXT);
              process.exit(0);
            }

            // ── PocketBase: authenticate ───────────────────────────────────────
            const raw = process.env.POCKETBASE_URL.replace(/\/$/, '');
            const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api';
            const coll = process.env.POCKETBASE_COLLECTION;

            const authRes = 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 (!authRes.ok) {
              await addReaction('-1');
              await postComment('❌ **PocketBase Bot**: PocketBase authentication failed. CC @' + owner + '/maintainers');
              process.exit(1);
            }
            const token = JSON.parse(authRes.body).token;

            // ── PocketBase: find record by slug ────────────────────────────────
            const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
            const filter = "(slug='" + slug.replace(/'/g, "''") + "')";
            const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', {
              headers: { 'Authorization': token }
            });
            const list = JSON.parse(listRes.body);
            const record = list.items && list.items[0];

            if (!record) {
              await addReaction('-1');
              await postComment(
                '❌ **PocketBase Bot**: No record found for slug `' + slug + '`.\n\n' +
                'Make sure the script was already pushed to PocketBase (JSON must exist and have been synced).'
              );
              process.exit(0);
            }

            // ── Shared helpers ─────────────────────────────────────────────────

            // Key=value parser: handles unquoted and "quoted" values
            function parseKVPairs(str) {
              const fields = {};
              let pos = 0;
              while (pos < str.length) {
                while (pos < str.length && /\s/.test(str[pos])) pos++;
                if (pos >= str.length) break;
                let keyStart = pos;
                while (pos < str.length && str[pos] !== '=' && !/\s/.test(str[pos])) pos++;
                const key = str.substring(keyStart, pos).trim();
                if (!key || pos >= str.length || str[pos] !== '=') { pos++; continue; }
                pos++;
                let value;
                if (pos < str.length && str[pos] === '"') {
                  pos++;
                  let valStart = pos;
                  while (pos < str.length && str[pos] !== '"') {
                    if (str[pos] === '\\') pos++;
                    pos++;
                  }
                  value = str.substring(valStart, pos).replace(/\\"/g, '"');
                  if (pos < str.length) pos++;
                } else {
                  let valStart = pos;
                  while (pos < str.length && !/\s/.test(str[pos])) pos++;
                  value = str.substring(valStart, pos);
                }
                fields[key] = value;
              }
              return fields;
            }

            // Token parser for note commands: unquoted-word OR "quoted string"
            function parseTokens(str) {
              const tokens = [];
              let pos = 0;
              while (pos < str.length) {
                while (pos < str.length && /\s/.test(str[pos])) pos++;
                if (pos >= str.length) break;
                if (str[pos] === '"') {
                  pos++;
                  let start = pos;
                  while (pos < str.length && str[pos] !== '"') {
                    if (str[pos] === '\\') pos++;
                    pos++;
                  }
                  tokens.push(str.substring(start, pos).replace(/\\"/g, '"'));
                  if (pos < str.length) pos++;
                } else {
                  let start = pos;
                  while (pos < str.length && !/\s/.test(str[pos])) pos++;
                  tokens.push(str.substring(start, pos));
                }
              }
              return tokens;
            }

            // Read JSON blob from record (handles parsed objects and strings)
            function readJsonBlob(val) {
              if (Array.isArray(val)) return val;
              try { return JSON.parse(val || '[]'); } catch (e) { return []; }
            }

            // Frontend cache revalidation (silent, best-effort)
            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); }
            }

            // Format notes list for display
            function formatNotesList(arr) {
              if (arr.length === 0) return '*None*';
              return arr.map(function (n, i) {
                return (i + 1) + '. **`' + (n.type || '?') + '`**: ' + (n.text || '');
              }).join('\n');
            }

            // Format install methods list for display
            function formatMethodsList(arr) {
              if (arr.length === 0) return '*None*';
              return arr.map(function (im, i) {
                const r = im.resources || {};
                const parts = [
                  (r.os || '?') + ' ' + (r.version || '?'),
                  (r.cpu != null ? r.cpu : '?') + 'C / ' + (r.ram != null ? r.ram : '?') + ' MB / ' + (r.hdd != null ? r.hdd : '?') + ' GB'
                ];
                if (im.config_path) parts.push('config: `' + im.config_path + '`');
                if (im.script) parts.push('script: `' + im.script + '`');
                return (i + 1) + '. **`' + (im.type || '?') + '`** — ' + parts.join(', ');
              }).join('\n');
            }

            // ── Route: dispatch to subcommand handler ──────────────────────────
            const infoMatch   = rest.match(/^info$/i);
            const noteMatch   = rest.match(/^note\s+(list|add|edit|remove)\b/i);
            const methodMatch = rest.match(/^method\b/i);
            const setMatch    = rest.match(/^set\s+(\S+)/i);

            if (infoMatch) {
              // ── INFO SUBCOMMAND ──────────────────────────────────────────────
              const notesArr   = readJsonBlob(record.notes);
              const methodsArr = readJsonBlob(record.install_methods);

              const out = [];
              out.push('ℹ️ **PocketBase Bot**: Info for **`' + slug + '`**\n');

              out.push('**Basic info:**');
              out.push('- **Name:** ' + (record.name || '—'));
              out.push('- **Slug:** `' + slug + '`');
              out.push('- **Port:** ' + (record.port != null ? '`' + record.port + '`' : '—'));
              out.push('- **Updateable:** ' + (record.updateable ? 'Yes' : 'No'));
              out.push('- **Privileged:** ' + (record.privileged ? 'Yes' : 'No'));
              out.push('- **ARM:** ' + (record.has_arm ? 'Yes' : 'No'));
              if (record.is_dev) out.push('- **Dev:** Yes');
              if (record.is_disabled) out.push('- **Disabled:** Yes' + (record.disable_message ? ' — ' + record.disable_message : ''));
              if (record.is_deleted) out.push('- **Deleted:** Yes' + (record.deleted_message ? ' — ' + record.deleted_message : ''));
              out.push('');

              out.push('**Links:**');
              out.push('- **Website:** ' + (record.website || '—'));
              out.push('- **Docs:** ' + (record.documentation || '—'));
              out.push('- **Logo:** ' + (record.logo ? '[link](' + record.logo + ')' : '—'));
              out.push('- **GitHub:** ' + (record.github || '—'));
              if (record.config_path) out.push('- **Config:** `' + record.config_path + '`');
              out.push('');

              out.push('**Credentials:**');
              out.push('- **User:** ' + (record.default_user || '—'));
              out.push('- **Password:** ' + (record.default_passwd ? '*(set)*' : '—'));
              out.push('');

              out.push('**Install methods** (' + methodsArr.length + '):');
              out.push(formatMethodsList(methodsArr));
              out.push('');

              out.push('**Notes** (' + notesArr.length + '):');
              out.push(formatNotesList(notesArr));

              await addReaction('+1');
              await postComment(out.join('\n'));

            } else if (noteMatch) {
              // ── NOTE SUBCOMMAND ──────────────────────────────────────────────
              const noteAction  = noteMatch[1].toLowerCase();
              const noteArgsStr = rest.substring(noteMatch[0].length).trim();
              let notesArr = readJsonBlob(record.notes);

              async function patchNotes(arr) {
                const res = await request(recordsUrl + '/' + record.id, {
                  method: 'PATCH',
                  headers: { 'Authorization': token, 'Content-Type': 'application/json' },
                  body: JSON.stringify({ notes: arr })
                });
                if (!res.ok) {
                  await addReaction('-1');
                  await postComment('❌ **PocketBase Bot**: Failed to update notes:\n```\n' + res.body + '\n```');
                  process.exit(1);
                }
              }

              if (noteAction === 'list') {
                await addReaction('+1');
                await postComment(
                  'ℹ️ **PocketBase Bot**: Notes for **`' + slug + '`** (' + notesArr.length + ' total)\n\n' +
                  formatNotesList(notesArr)
                );

              } else if (noteAction === 'add') {
                const tokens = parseTokens(noteArgsStr);
                if (tokens.length < 2) {
                  await addReaction('-1');
                  await postComment(
                    '❌ **PocketBase Bot**: `note add` requires `<type>` and `"<text>"`.\n\n' +
                    '**Usage:** `/pocketbase ' + slug + ' note add <type> "<text>"`'
                  );
                  process.exit(0);
                }
                const noteType = tokens[0].toLowerCase();
                const noteText = tokens.slice(1).join(' ');
                notesArr.push({ type: noteType, text: noteText });
                await patchNotes(notesArr);
                await revalidate(slug);
                await addReaction('+1');
                await postComment(
                  '✅ **PocketBase Bot**: Added note to **`' + slug + '`**\n\n' +
                  '- **Type:** `' + noteType + '`\n' +
                  '- **Text:** ' + noteText + '\n\n' +
                  '*Executed by @' + actor + '*'
                );

              } else if (noteAction === 'edit') {
                const tokens = parseTokens(noteArgsStr);
                if (tokens.length < 3) {
                  await addReaction('-1');
                  await postComment(
                    '❌ **PocketBase Bot**: `note edit` requires `<type>`, `"<old text>"`, and `"<new text>"`.\n\n' +
                    '**Usage:** `/pocketbase ' + slug + ' note edit <type> "<old text>" "<new text>"`\n\n' +
                    'Use `/pocketbase ' + slug + ' note list` to see current notes.'
                  );
                  process.exit(0);
                }
                const noteType = tokens[0].toLowerCase();
                const oldText  = tokens[1];
                const newText  = tokens[2];
                const idx = notesArr.findIndex(function (n) {
                  return n.type.toLowerCase() === noteType && n.text === oldText;
                });
                if (idx === -1) {
                  await addReaction('-1');
                  await postComment(
                    '❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' +
                    '**Current notes for `' + slug + '`:**\n' + formatNotesList(notesArr)
                  );
                  process.exit(0);
                }
                notesArr[idx].text = newText;
                await patchNotes(notesArr);
                await revalidate(slug);
                await addReaction('+1');
                await postComment(
                  '✅ **PocketBase Bot**: Edited note in **`' + slug + '`**\n\n' +
                  '- **Type:** `' + noteType + '`\n' +
                  '- **Old:** ' + oldText + '\n' +
                  '- **New:** ' + newText + '\n\n' +
                  '*Executed by @' + actor + '*'
                );

              } else if (noteAction === 'remove') {
                const tokens = parseTokens(noteArgsStr);
                if (tokens.length < 2) {
                  await addReaction('-1');
                  await postComment(
                    '❌ **PocketBase Bot**: `note remove` requires `<type>` and `"<text>"`.\n\n' +
                    '**Usage:** `/pocketbase ' + slug + ' note remove <type> "<text>"`\n\n' +
                    'Use `/pocketbase ' + slug + ' note list` to see current notes.'
                  );
                  process.exit(0);
                }
                const noteType = tokens[0].toLowerCase();
                const noteText = tokens[1];
                const before   = notesArr.length;
                notesArr = notesArr.filter(function (n) {
                  return !(n.type.toLowerCase() === noteType && n.text === noteText);
                });
                if (notesArr.length === before) {
                  await addReaction('-1');
                  await postComment(
                    '❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' +
                    '**Current notes for `' + slug + '`:**\n' + formatNotesList(notesArr)
                  );
                  process.exit(0);
                }
                await patchNotes(notesArr);
                await revalidate(slug);
                await addReaction('+1');
                await postComment(
                  '✅ **PocketBase Bot**: Removed note from **`' + slug + '`**\n\n' +
                  '- **Type:** `' + noteType + '`\n' +
                  '- **Text:** ' + noteText + '\n\n' +
                  '*Executed by @' + actor + '*'
                );
              }

            } else if (methodMatch) {
              // ── METHOD SUBCOMMAND ────────────────────────────────────────────
              const methodArgs     = rest.replace(/^method\s*/i, '').trim();
              const methodListMode = !methodArgs || methodArgs.toLowerCase() === 'list';
              let methodsArr = readJsonBlob(record.install_methods);

              // Method field classification
              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 applyMethodChanges(method, parsed) {
                if (!method.resources) method.resources = {};
                for (const [k, v] of Object.entries(parsed)) {
                  if (RESOURCE_KEYS[k]) {
                    method.resources[k] = RESOURCE_KEYS[k] === 'number' ? parseInt(v, 10) : v;
                  } else if (METHOD_KEYS[k]) {
                    method[k] = v === '' ? null : v;
                  }
                }
              }

              async function patchMethods(arr) {
                const res = await request(recordsUrl + '/' + record.id, {
                  method: 'PATCH',
                  headers: { 'Authorization': token, 'Content-Type': 'application/json' },
                  body: JSON.stringify({ install_methods: arr })
                });
                if (!res.ok) {
                  await addReaction('-1');
                  await postComment('❌ **PocketBase Bot**: Failed to update install methods:\n```\n' + res.body + '\n```');
                  process.exit(1);
                }
              }

              if (methodListMode) {
                await addReaction('+1');
                await postComment(
                  'ℹ️ **PocketBase Bot**: Install methods for **`' + slug + '`** (' + methodsArr.length + ' total)\n\n' +
                  formatMethodsList(methodsArr)
                );

              } else {
                // Check for add / remove sub-actions
                const addMatch    = methodArgs.match(/^add\s+(\S+)(?:\s+(.+))?$/i);
                const removeMatch = methodArgs.match(/^remove\s+(\S+)$/i);

                if (addMatch) {
                  // ── METHOD ADD ───────────────────────────────────────────────
                  const newType = addMatch[1];
                  const parsed = addMatch[2] ? parseKVPairs(addMatch[2]) : {};
                  if (methodsArr.some(function (im) { return (im.type || '').toLowerCase() === newType.toLowerCase(); })) {
                    await addReaction('-1');
                    await postComment('❌ **PocketBase Bot**: Install method `' + newType + '` already exists for `' + slug + '`.\n\nUse `/pocketbase ' + slug + ' method list` to see all methods.');
                    process.exit(0);
                  }
                  const newMethod = { type: newType, resources: { cpu: 1, ram: 512, hdd: 4, os: 'debian', version: '13' } };
                  if (addMatch[2]) {
                    const unknown = Object.keys(parsed).filter(function (k) { return !ALL_METHOD_KEYS[k]; });
                    if (unknown.length > 0) {
                      await addReaction('-1');
                      await postComment('❌ **PocketBase Bot**: Unknown method field(s): `' + unknown.join('`, `') + '`\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
                      process.exit(0);
                    }
                    applyMethodChanges(newMethod, parsed);
                  }
                  methodsArr.push(newMethod);
                  await patchMethods(methodsArr);
                  await revalidate(slug);
                  const addCtChanges = {};
                  for (const [k, v] of Object.entries(parsed)) {
                    if (RESOURCE_TO_CT_VAR[k]) addCtChanges[RESOURCE_TO_CT_VAR[k]] = v;
                  }
                  let addCtSync = null;
                  try {
                    addCtSync = await upsertCtDefaultsPr(slug, addCtChanges);
                  } catch (e) {
                    addCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
                  }
                  await addReaction('+1');
                  await postComment(
                    '✅ **PocketBase Bot**: Added install method **`' + newType + '`** to **`' + slug + '`**\n\n' +
                    formatMethodsList([newMethod]) + '\n\n' +
                    formatCtSyncResult(addCtSync) + '\n\n' +
                    '*Executed by @' + actor + '*'
                  );

                } else if (removeMatch) {
                  // ── METHOD REMOVE ────────────────────────────────────────────
                  const removeType = removeMatch[1].toLowerCase();
                  const removed = methodsArr.filter(function (im) { return (im.type || '').toLowerCase() === removeType; });
                  if (removed.length === 0) {
                    await addReaction('-1');
                    const available = methodsArr.map(function (im) { return im.type || '?'; });
                    await postComment('❌ **PocketBase Bot**: No install method `' + removeType + '` found.\n\n**Available:** `' + (available.length ? available.join('`, `') : '(none)') + '`');
                    process.exit(0);
                  }
                  methodsArr = methodsArr.filter(function (im) { return (im.type || '').toLowerCase() !== removeType; });
                  await patchMethods(methodsArr);
                  await revalidate(slug);
                  await addReaction('+1');
                  await postComment(
                    '✅ **PocketBase Bot**: Removed install method **`' + removed[0].type + '`** from **`' + slug + '`**\n\n' +
                    '*Executed by @' + actor + '*'
                  );

                } else {
                  // ── METHOD EDIT ──────────────────────────────────────────────
                  const editParts = methodArgs.match(/^(\S+)\s+(.+)$/);
                  if (!editParts) {
                    await addReaction('-1');
                    await postComment(
                      '❌ **PocketBase Bot**: Invalid `method` syntax.\n\n' +
                      '**Usage:**\n```\n/pocketbase ' + slug + ' method list\n' +
                      '/pocketbase ' + slug + ' method <type> cpu=4 ram=2048 hdd=20\n' +
                      '/pocketbase ' + slug + ' method <type> config_path="/opt/app/.env"\n' +
                      '/pocketbase ' + slug + ' method add <type> cpu=2 ram=2048 hdd=8\n' +
                      '/pocketbase ' + slug + ' method remove <type>\n```'
                    );
                    process.exit(0);
                  }
                  const targetType = editParts[1].toLowerCase();
                  const parsed = parseKVPairs(editParts[2]);

                  const unknown = Object.keys(parsed).filter(function (k) { return !ALL_METHOD_KEYS[k]; });
                  if (unknown.length > 0) {
                    await addReaction('-1');
                    await postComment('❌ **PocketBase Bot**: Unknown method field(s): `' + unknown.join('`, `') + '`\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
                    process.exit(0);
                  }
                  if (Object.keys(parsed).length === 0) {
                    await addReaction('-1');
                    await postComment('❌ **PocketBase Bot**: No valid `key=value` pairs found.\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
                    process.exit(0);
                  }

                  const idx = methodsArr.findIndex(function (im) { return (im.type || '').toLowerCase() === targetType; });
                  if (idx === -1) {
                    await addReaction('-1');
                    const available = methodsArr.map(function (im) { return im.type || '?'; });
                    await postComment(
                      '❌ **PocketBase Bot**: No install method `' + targetType + '` found for `' + slug + '`.\n\n' +
                      '**Available:** `' + (available.length ? available.join('`, `') : '(none)') + '`\n\n' +
                      'Use `/pocketbase ' + slug + ' method list` to see all methods.'
                    );
                    process.exit(0);
                  }

                  applyMethodChanges(methodsArr[idx], parsed);
                  await patchMethods(methodsArr);
                  await revalidate(slug);
                  const editCtChanges = {};
                  for (const [k, v] of Object.entries(parsed)) {
                    if (RESOURCE_TO_CT_VAR[k]) editCtChanges[RESOURCE_TO_CT_VAR[k]] = v;
                  }
                  let editCtSync = null;
                  try {
                    editCtSync = await upsertCtDefaultsPr(slug, editCtChanges);
                  } catch (e) {
                    editCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
                  }

                  const changesLines = Object.entries(parsed)
                    .map(function ([k, v]) {
                      const unit = k === 'ram' ? ' MB' : k === 'hdd' ? ' GB' : '';
                      return '- `' + k + '` → `' + v + unit + '`';
                    }).join('\n');
                  await addReaction('+1');
                  await postComment(
                    '✅ **PocketBase Bot**: Updated install method **`' + methodsArr[idx].type + '`** for **`' + slug + '`**\n\n' +
                    '**Changes applied:**\n' + changesLines + '\n\n' +
                    formatCtSyncResult(editCtSync) + '\n\n' +
                    '*Executed by @' + actor + '*'
                  );
                }
              }

            } else if (setMatch) {
              // ── SET SUBCOMMAND (value from code block) ───────────────────────
              const fieldName = setMatch[1].toLowerCase();
              const SET_ALLOWED = {
                name: 'string', description: 'string', logo: 'string',
                documentation: 'string', website: 'string', project_url: 'string', github: 'string',
                config_path: 'string', disable_message: 'string', deleted_message: 'string'
              };
              if (!SET_ALLOWED[fieldName]) {
                await addReaction('-1');
                await postComment(
                  '❌ **PocketBase Bot**: `set` only supports text fields.\n\n' +
                  '**Allowed:** `' + Object.keys(SET_ALLOWED).join('`, `') + '`\n\n' +
                  'For boolean/number fields use `field=value` syntax instead.'
                );
                process.exit(0);
              }
              if (!codeBlockValue) {
                await addReaction('-1');
                await postComment(
                  '❌ **PocketBase Bot**: `set` requires a code block with the value.\n\n' +
                  '**Usage:**\n````\n/pocketbase ' + slug + ' set ' + fieldName + '\n```\nYour content here (HTML, multiline, special chars all fine)\n```\n````'
                );
                process.exit(0);
              }
              const setPayload = {};
              setPayload[fieldName] = codeBlockValue;
              const setPatchRes = await request(recordsUrl + '/' + record.id, {
                method: 'PATCH',
                headers: { 'Authorization': token, 'Content-Type': 'application/json' },
                body: JSON.stringify(setPayload)
              });
              if (!setPatchRes.ok) {
                await addReaction('-1');
                await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + setPatchRes.body + '\n```');
                process.exit(1);
              }
              await revalidate(slug);
              const preview = codeBlockValue.length > 300 ? codeBlockValue.substring(0, 300) + '…' : codeBlockValue;
              await addReaction('+1');
              await postComment(
                '✅ **PocketBase Bot**: Set `' + fieldName + '` for **`' + slug + '`**\n\n' +
                '**Value set:**\n```\n' + preview + '\n```\n\n' +
                '*Executed by @' + actor + '*'
              );

            } else {
              // ── FIELD=VALUE PATH ─────────────────────────────────────────────
              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 parsedFields = parseKVPairs(rest);

              const unknownFields = Object.keys(parsedFields).filter(function (f) { return !ALLOWED_FIELDS[f]; });
              if (unknownFields.length > 0) {
                await addReaction('-1');
                await postComment(
                  '❌ **PocketBase Bot**: Unknown field(s): `' + unknownFields.join('`, `') + '`\n\n' +
                  '**Allowed fields:** `' + Object.keys(ALLOWED_FIELDS).join('`, `') + '`'
                );
                process.exit(0);
              }

              if (Object.keys(parsedFields).length === 0) {
                await addReaction('-1');
                await postComment('❌ **PocketBase Bot**: Could not parse any valid `field=value` pairs.\n\n' + HELP_TEXT);
                process.exit(0);
              }

              // Cast values to correct types
              const payload = {};
              for (const [key, rawVal] of Object.entries(parsedFields)) {
                const type = ALLOWED_FIELDS[key];
                if (type === 'boolean') {
                  if (rawVal === 'true') payload[key] = true;
                  else if (rawVal === 'false') payload[key] = false;
                  else {
                    await addReaction('-1');
                    await postComment('❌ **PocketBase Bot**: `' + key + '` must be `true` or `false`, got: `' + rawVal + '`');
                    process.exit(0);
                  }
                } else if (type === 'number') {
                  const n = parseInt(rawVal, 10);
                  if (isNaN(n)) {
                    await addReaction('-1');
                    await postComment('❌ **PocketBase Bot**: `' + key + '` must be a number, got: `' + rawVal + '`');
                    process.exit(0);
                  }
                  payload[key] = n;
                } else if (type === 'nullable_string') {
                  payload[key] = rawVal === '' ? null : rawVal;
                } else {
                  payload[key] = rawVal;
                }
              }

              const patchRes = await request(recordsUrl + '/' + record.id, {
                method: 'PATCH',
                headers: { 'Authorization': token, 'Content-Type': 'application/json' },
                body: JSON.stringify(payload)
              });
              if (!patchRes.ok) {
                await addReaction('-1');
                await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + patchRes.body + '\n```');
                process.exit(1);
              }
              await revalidate(slug);
              const FIELD_TO_CT_VAR = { tags: 'var_tags', unprivileged: 'var_unprivileged' };
              const fieldCtChanges = {};
              for (const [k, v] of Object.entries(payload)) {
                if (FIELD_TO_CT_VAR[k]) fieldCtChanges[FIELD_TO_CT_VAR[k]] = v;
              }
              let fieldCtSync = null;
              try {
                fieldCtSync = await upsertCtDefaultsPr(slug, fieldCtChanges);
              } catch (e) {
                fieldCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
              }
              await addReaction('+1');
              const changesLines = Object.entries(payload)
                .map(function ([k, v]) { return '- `' + k + '` → `' + JSON.stringify(v) + '`'; })
                .join('\n');
              await postComment(
                '✅ **PocketBase Bot**: Updated **`' + slug + '`** successfully!\n\n' +
                '**Changes applied:**\n' + changesLines + '\n\n' +
                formatCtSyncResult(fieldCtSync) + '\n\n' +
                '*Executed by @' + actor + '*'
              );
            }

            console.log('Done.');
          })().catch(function (e) {
            console.error('Fatal error:', e.message || e);
            process.exit(1);
          });
          ENDSCRIPT
        shell: bash
