/* github.jsx — real GitHub integration. Browser-only, PAT-based.
 *
 * What it does:
 *   - testToken(pat) → confirms the PAT works, returns { login, name, avatar }
 *   - listRepos(pat) → returns user repos (sorted by recently pushed)
 *   - resolveHeadInjection(pat, repo) → finds a sensible file + insertion point
 *       for HTML <head> changes (index.html / layout.tsx / _document.tsx / etc.)
 *   - openFixPR({pat, repo, fix, brand}) → creates branch, commits the changed
 *       file, opens a real PR, returns { url, number }
 *
 * Token storage: localStorage 'aiso:github:v1' = { pat, user, repo }
 *
 * Required PAT scopes (fine-grained recommended):
 *   - Contents: read/write   (to read + write files)
 *   - Pull requests: write   (to open PRs)
 *   - Metadata: read         (default)
 */

const GH_API = 'https://api.github.com';
const GH_KEY = 'aiso:github:v1';

function loadGh() {
  try { return JSON.parse(localStorage.getItem(GH_KEY) || '{}'); }
  catch { return {}; }
}
function saveGh(patch) {
  const cur = loadGh();
  const next = { ...cur, ...patch };
  localStorage.setItem(GH_KEY, JSON.stringify(next));
  window.dispatchEvent(new CustomEvent('aiso:gh-changed'));
  return next;
}
function clearGh() {
  localStorage.removeItem(GH_KEY);
  window.dispatchEvent(new CustomEvent('aiso:gh-changed'));
}

async function ghFetch(pat, path, opts = {}) {
  const headers = {
    'Authorization': `Bearer ${pat}`,
    'Accept': 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    ...(opts.headers || {}),
  };
  if (opts.body && typeof opts.body !== 'string') {
    headers['Content-Type'] = 'application/json';
    opts.body = JSON.stringify(opts.body);
  }
  const url = path.startsWith('http') ? path : `${GH_API}${path}`;
  const r = await fetch(url, { ...opts, headers });
  if (!r.ok) {
    let msg = `HTTP ${r.status}`;
    try {
      const data = await r.json();
      if (data.message) msg = `${msg} · ${data.message}`;
      if (data.errors) msg += ` · ${JSON.stringify(data.errors).slice(0, 200)}`;
    } catch {}
    const err = new Error(msg);
    err.status = r.status;
    throw err;
  }
  if (r.status === 204) return null;
  return r.json();
}

async function testToken(pat) {
  const me = await ghFetch(pat, '/user');
  return {
    login: me.login,
    name: me.name || me.login,
    avatar: me.avatar_url,
    type: me.type,
  };
}

async function listRepos(pat, { perPage = 100 } = {}) {
  // Owned + collaborator + org-member repos, most recently pushed first.
  const repos = await ghFetch(pat, `/user/repos?sort=pushed&per_page=${perPage}&affiliation=owner,collaborator,organization_member`);
  return repos.map(r => ({
    fullName: r.full_name,
    name: r.name,
    owner: r.owner.login,
    defaultBranch: r.default_branch,
    private: r.private,
    pushedAt: r.pushed_at,
    description: r.description,
    htmlUrl: r.html_url,
    homepage: r.homepage,
    language: r.language,
  }));
}

/* Identify the file in the repo that hosts the <head> we should edit.
 * Returns { path, content, sha, insertion: 'before-head-close' | 'top-of-head' | 'append', kind }
 * Returns null if nothing recognizable found.
 */
const HEAD_CANDIDATES = [
  // Next.js / React frameworks
  { path: 'src/app/layout.tsx', kind: 'next-app-tsx' },
  { path: 'src/app/layout.jsx', kind: 'next-app-jsx' },
  { path: 'app/layout.tsx', kind: 'next-app-tsx' },
  { path: 'app/layout.jsx', kind: 'next-app-jsx' },
  { path: 'pages/_document.tsx', kind: 'next-pages' },
  { path: 'pages/_document.jsx', kind: 'next-pages' },
  { path: 'pages/_document.js', kind: 'next-pages' },
  // Astro
  { path: 'src/layouts/Layout.astro', kind: 'astro' },
  { path: 'src/layouts/BaseLayout.astro', kind: 'astro' },
  // Static HTML
  { path: 'index.html', kind: 'html' },
  { path: 'AISO.html', kind: 'html' },
  { path: 'public/index.html', kind: 'html' },
  { path: 'dist/index.html', kind: 'html' },
  // Hugo / Jekyll / Eleventy
  { path: 'layouts/_default/baseof.html', kind: 'hugo' },
  { path: '_layouts/default.html', kind: 'jekyll' },
  { path: '_includes/head.html', kind: 'jekyll-partial' },
  { path: 'src/_includes/layouts/base.njk', kind: 'eleventy' },
  // Vue/Nuxt/SvelteKit
  { path: 'nuxt.config.ts', kind: 'nuxt-config' },
  { path: 'src/app.html', kind: 'sveltekit' },
];

async function resolveHeadInjection(pat, repo) {
  const owner = repo.owner;
  const name = repo.name;
  for (const cand of HEAD_CANDIDATES) {
    try {
      const data = await ghFetch(pat, `/repos/${owner}/${name}/contents/${encodeURIComponent(cand.path)}`);
      if (!data || !data.content) continue;
      // UTF-8-aware decode: plain atob() returns Latin-1, which mangles —, •, ✓ etc.
      // into mojibake (â, â¢) that then ships in the PR. escape→atob→decodeURIComponent
      // round-trips correctly with the btoa(unescape(encodeURIComponent())) on the way out.
      const content = decodeURIComponent(escape(atob(data.content.replace(/\s/g, ''))));
      // Check it actually contains a head/Head element we can target
      const hasHeadEl = /<head[\s>]/i.test(content) || /<Head[\s>]/.test(content) || cand.kind === 'jekyll-partial';
      if (!hasHeadEl) continue;
      return {
        path: cand.path,
        kind: cand.kind,
        content,
        sha: data.sha,
        insertion: cand.kind === 'jekyll-partial' ? 'append' : 'before-head-close',
      };
    } catch (e) {
      if (e.status === 404) continue;
      throw e;
    }
  }
  return null;
}

/* Given a fix object (with a payload.code that's a <script type=... ld+json> or
 * a block of <meta> tags), produce the new file contents with the snippet
 * inserted in the right spot.
 */
function injectSnippet(target, snippet) {
  const { content, kind, insertion } = target;
  const wrappedSnippet = '\n    ' + snippet.replace(/\n/g, '\n    ') + '\n  ';

  if (insertion === 'append') {
    // Jekyll partial — just append
    return content + '\n' + snippet + '\n';
  }

  if (kind === 'next-app-tsx' || kind === 'next-app-jsx') {
    // Find <head> ... </head> block (JSX); insert before </head>
    const m = content.match(/<\/head>/i);
    if (m) {
      const idx = m.index;
      // JSX needs script wrapped in `dangerouslySetInnerHTML` for JSON-LD.
      const jsxWrapped = jsxWrapSnippet(snippet);
      return content.slice(0, idx) + jsxWrapped + '\n      ' + content.slice(idx);
    }
    // Fall back: append note at top of file with TODO
    return null;
  }

  if (kind === 'next-pages') {
    // _document — has <Head> ... </Head>
    const m = content.match(/<\/Head>/);
    if (m) {
      const jsxWrapped = jsxWrapSnippet(snippet);
      return content.slice(0, m.index) + jsxWrapped + '\n        ' + content.slice(m.index);
    }
    return null;
  }

  if (kind === 'astro') {
    // Astro: HTML-style <head>
    const m = content.match(/<\/head>/i);
    if (m) return content.slice(0, m.index) + wrappedSnippet + content.slice(m.index);
    return null;
  }

  if (kind === 'sveltekit') {
    // src/app.html has %sveltekit.head% placeholder
    const m = content.match(/%sveltekit\.head%/);
    if (m) return content.replace('%sveltekit.head%', wrappedSnippet + '%sveltekit.head%');
    return null;
  }

  // html / hugo / jekyll / eleventy — all use <head>...</head>
  const m = content.match(/<\/head>/i);
  if (m) return content.slice(0, m.index) + wrappedSnippet + content.slice(m.index);

  return null;
}

function jsxWrapSnippet(snippet) {
  // If it's a <script type="application/ld+json">JSON</script>, wrap with dangerouslySetInnerHTML
  const sm = snippet.match(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/i);
  if (sm) {
    const json = sm[1].trim();
    return `<script\n        type="application/ld+json"\n        dangerouslySetInnerHTML={{\n          __html: ${'`'}${json.replace(/`/g, '\\`')}${'`'}\n        }}\n      />`;
  }
  // Otherwise treat as raw JSX (meta tags)
  return snippet;
}

/* Open a real PR for this fix on the configured repo. */
async function openFixPR({ pat, repo, fix, brand }) {
  if (!pat) throw new Error('No GitHub token');
  if (!repo) throw new Error('No repo selected');
  if (!fix?.payload?.code) throw new Error('Fix has no payload to ship');

  const steps = [];
  const step = (label, sub = '') => { steps.push({ label, sub, at: Date.now() }); };

  step('Resolving target file', `Searching ${repo.fullName} for a <head>…`);
  const target = await resolveHeadInjection(pat, repo);
  if (!target) {
    throw new Error('Couldn’t find a file with a <head> element. Add /index.html or /src/app/layout.tsx to your repo, or paste the snippet manually.');
  }
  step(`Found ${target.path}`, `framework: ${target.kind}`);

  const newContent = injectSnippet(target, fix.payload.code);
  if (!newContent) {
    throw new Error(`Couldn’t safely inject into ${target.path} — opening as a separate file instead.`);
  }

  // Create branch from default
  const ts = new Date().toISOString().slice(0, 10);
  const slug = (fix.recipeId || 'fix').toLowerCase().replace(/[^a-z0-9]+/g, '-');
  const branchName = `aiso/${slug}-${ts}-${Math.random().toString(36).slice(2, 6)}`;

  // Dedupe at the source: if an OPEN PR for this fix already exists on the repo, return it
  // instead of opening a duplicate. Branch prefix `aiso/{slug}-` identifies the fix, so this
  // catches PRs opened on another device or before local tracking existed.
  step('Checking for an existing PR', `on ${repo.fullName}…`);
  try {
    const openPrs = await ghFetch(pat, `/repos/${repo.fullName}/pulls?state=open&per_page=100`);
    const existing = (openPrs || []).find((p) => ((p.head && p.head.ref) || '').startsWith(`aiso/${slug}-`));
    if (existing) {
      step(`Already open: PR #${existing.number}`, existing.html_url);
      return { url: existing.html_url, number: existing.number, branch: (existing.head && existing.head.ref), file: target.path, steps, existing: true };
    }
  } catch (e) { /* lookup failed — fall through and open normally */ }

  step(`Creating branch ${branchName}`, `from ${repo.defaultBranch}`);
  const mainRef = await ghFetch(pat, `/repos/${repo.fullName}/git/ref/heads/${repo.defaultBranch}`);
  await ghFetch(pat, `/repos/${repo.fullName}/git/refs`, {
    method: 'POST',
    body: { ref: `refs/heads/${branchName}`, sha: mainRef.object.sha },
  });

  step(`Committing change to ${target.path}`, `+${(newContent.length - target.content.length)} chars`);
  const commitMsg = `AISO: ${fix.title}\n\nGenerated by AISO for ${brand?.name || 'your brand'} (${brand?.domain || ''}).\nProjected visibility lift: +${fix.gain}.\n`;
  await ghFetch(pat, `/repos/${repo.fullName}/contents/${encodeURIComponent(target.path)}`, {
    method: 'PUT',
    body: {
      message: commitMsg,
      content: btoa(unescape(encodeURIComponent(newContent))),
      sha: target.sha,
      branch: branchName,
    },
  });

  // Open PR
  step('Opening pull request', '');
  const prBody = buildPrBody(fix, brand, target);
  const pr = await ghFetch(pat, `/repos/${repo.fullName}/pulls`, {
    method: 'POST',
    body: {
      title: `AISO · ${fix.title} (+${fix.gain} visibility)`,
      head: branchName,
      base: repo.defaultBranch,
      body: prBody,
    },
  });

  step(`PR #${pr.number} opened`, pr.html_url);
  return {
    url: pr.html_url,
    number: pr.number,
    branch: branchName,
    file: target.path,
    path: target.path,
    // Saved so a fix can be reverted later. Skip if the file is huge (localStorage budget).
    originalContent: (target.content && target.content.length < 400000) ? target.content : null,
    steps,
  };
}

/* File-level diff for a PR (powers the inline diff view). */
async function ghFetchPrFiles(pat, repo, number) {
  const files = await ghFetch(pat, `/repos/${repo.fullName}/pulls/${number}/files`);
  return (files || []).map((f) => ({ filename: f.filename, additions: f.additions, deletions: f.deletions, patch: f.patch || '' }));
}

/* Merge a PR (squash) and best-effort delete its branch. */
async function ghMergePR(pat, repo, number, branch) {
  const res = await ghFetch(pat, `/repos/${repo.fullName}/pulls/${number}/merge`, {
    method: 'PUT',
    body: { merge_method: 'squash' },
  });
  if (branch) { try { await ghFetch(pat, `/repos/${repo.fullName}/git/refs/heads/${branch}`, { method: 'DELETE' }); } catch {} }
  return { merged: !!res.merged, sha: res.sha };
}

/* One-click revert: restore the file to its pre-fix content on the default branch. */
async function ghRevertFix(pat, repo, { path, originalContent, title }) {
  if (!path || originalContent == null) throw new Error('Nothing saved to restore for this fix.');
  const cur = await ghFetch(pat, `/repos/${repo.fullName}/contents/${encodeURIComponent(path)}?ref=${repo.defaultBranch}`);
  await ghFetch(pat, `/repos/${repo.fullName}/contents/${encodeURIComponent(path)}`, {
    method: 'PUT',
    body: {
      message: `Revert: ${title || 'AISO fix'} — rolled back by AISO`,
      content: btoa(unescape(encodeURIComponent(originalContent))),
      sha: cur.sha,
      branch: repo.defaultBranch,
    },
  });
  return { reverted: true, at: Date.now() };
}

/* Verify a snippet is live in the site's production HTML (best-effort, via the fetch proxy). */
async function ghVerifyLive(domain, needle) {
  if (!domain || !needle) return { ok: false };
  const target = `https://${String(domain).replace(/^https?:\/\//, '').replace(/\/.*$/, '')}/`;
  try {
    let html = '';
    const base = window.AISO_API_BASE;
    if (base) {
      const r = await fetch(`${base}/fetch?url=${encodeURIComponent(target)}`);
      const d = await r.json().catch(() => ({}));
      html = d.contents || d.body || d.html || (typeof d === 'string' ? d : '');
    } else {
      html = await (await fetch(target)).text();
    }
    return { ok: html.includes(needle) };
  } catch (e) { return { ok: false, error: e.message }; }
}

/* List open PRs on a repo (for the in-app PR manager). */
async function ghListOpenPrs(pat, repo) {
  const prs = await ghFetch(pat, `/repos/${repo.fullName}/pulls?state=open&per_page=100`);
  return (prs || []).map((p) => ({ number: p.number, title: p.title, url: p.html_url, branch: p.head && p.head.ref, createdAt: p.created_at }));
}

/* All PRs with state flags — so the report can show open ('Review') vs merged ('Shipped'). */
async function ghListPrs(pat, repo) {
  const prs = await ghFetch(pat, `/repos/${repo.fullName}/pulls?state=all&per_page=100`);
  return (prs || []).map((p) => ({ number: p.number, title: p.title, url: p.html_url, branch: p.head && p.head.ref, open: p.state === 'open', merged: !!p.merged_at }));
}

/* Close a PR (and best-effort delete its branch). */
async function ghClosePr(pat, repo, number, branch) {
  await ghFetch(pat, `/repos/${repo.fullName}/pulls/${number}`, { method: 'PATCH', body: { state: 'closed' } });
  if (branch) { try { await ghFetch(pat, `/repos/${repo.fullName}/git/refs/heads/${branch}`, { method: 'DELETE' }); } catch {} }
  return { closed: true };
}

function buildPrBody(fix, brand, target) {
  return `## What this PR does

AISO detected that **${(brand?.name || 'your site')}** is missing **${fix.title.toLowerCase()}**, and is opening this PR with the generated fix.

**Projected impact:** +${fix.gain} visibility lift across AI assistants (Claude, GPT, Gemini, Perplexity).

**File changed:** \`${target.path}\` (framework: ${target.kind})

## Why it matters

${fix.desc}

## What you should review

1. Confirm the inserted snippet renders in your \`<head>\` after build.
2. Validate the JSON-LD with [Google's Rich Results Test](https://search.google.com/test/rich-results?url=https://${brand?.domain || 'example.com'}/).
3. Once merged, AISO will re-scan in 24h to measure the lift.

---

Generated by [AISO](https://aiso.ai) · ${new Date().toISOString().split('T')[0]}
`;
}

/* ---------- OAuth: one-click connect (needs the owner's GitHub OAuth App + env vars) ---------- */
async function ghOAuthConfig() {
  try {
    const r = await fetch('/api/github-oauth');
    if (!r.ok) return { configured: false };
    return await r.json();
  } catch { return { configured: false }; }
}
function ghOAuthStart(clientId) {
  const redirect = encodeURIComponent(window.location.origin + '/api/github-callback');
  window.location.href = `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent(clientId)}&scope=repo&redirect_uri=${redirect}`;
}
async function ghOAuthExchange(code) {
  const r = await fetch('/api/github-oauth', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }) });
  const data = await r.json();
  if (!r.ok || !data.access_token) throw new Error(data.error || 'oauth_failed');
  return data.access_token;
}

/* React component: GitHub connector for Settings */
function GitHubConnector({ profile, onChange, onPickRepo, site, repoOnly }) {
  const [state, setState] = React.useState(() => loadGh());
  const [token, setToken] = React.useState(state.pat || '');
  const [reveal, setReveal] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [repos, setRepos] = React.useState(null);
  const [search, setSearch] = React.useState('');
  const [oauth, setOauth] = React.useState({ configured: false });
  React.useEffect(() => { ghOAuthConfig().then(setOauth); }, []);

  React.useEffect(() => {
    const onCh = () => setState(loadGh());
    window.addEventListener('aiso:gh-changed', onCh);
    return () => window.removeEventListener('aiso:gh-changed', onCh);
  }, []);

  async function connect() {
    if (!token.trim()) return;
    setBusy(true); setError(null);
    try {
      const user = await testToken(token.trim());
      saveGh({ pat: token.trim(), user });
      setState(loadGh());
      onChange?.();
      // immediately fetch repos
      const list = await listRepos(token.trim());
      setRepos(list);
    } catch (e) {
      setError(e.message);
    } finally {
      setBusy(false);
    }
  }

  async function loadRepoList() {
    if (!state.pat) return;
    setBusy(true);
    try {
      const list = await listRepos(state.pat);
      setRepos(list);
    } catch (e) {
      setError(e.message);
    } finally {
      setBusy(false);
    }
  }

  function pickRepo(repo) {
    // Per-site: save the repo onto the ACTIVE site (via onPickRepo) so each site ships
    // to its OWN repo. Fall back to global storage when no handler is wired.
    if (onPickRepo) onPickRepo(repo); else saveGh({ repo });
    setState(loadGh());
    onChange?.();
  }

  function disconnect() {
    clearGh();
    setState({});
    setRepos(null);
    setToken('');
  }

  const connected = !!state.pat && !!state.user;
  // Repo is per-site: show the active site's repo (falls back to any legacy global repo).
  // Target site: an explicit `site` (e.g. the one being edited) wins, else the active site.
  const targetSite = site || (profile?.sites || []).find((s) => s.id === profile?.activeSiteId) || null;
  const repo = (targetSite && targetSite.repo) || null; // strictly per-site, no global fallback
  // Enforce one repo per site: repos already wired to ANOTHER site can't be picked here.
  const usedElsewhere = new Map();
  (profile?.sites || []).forEach((s) => { if (s.id !== (targetSite && targetSite.id) && s.repo) usedElsewhere.set(s.repo.fullName, s.name || s.domain); });
  const filtered = (repos || []).filter(r => !search || r.fullName.toLowerCase().includes(search.toLowerCase()));

  return (
    <div className="gh-panel">
      <div className="gh-head">
        <div className="gh-logo">
          <svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
        </div>
        <div className="gh-head-text">
          <h3>GitHub</h3>
          <p>Real PR shipping. We commit fixes to a branch and open a pull request — you review and merge.</p>
        </div>
      </div>

      {!connected && repoOnly && (
        <div style={{ fontSize: 13, color: 'var(--fg-3)', padding: '4px 0' }}>Connect a GitHub account in <strong style={{ color: 'var(--fg-1)' }}>Settings → Integrations</strong> to pick a repo for this site.</div>
      )}
      {!connected && !repoOnly && (
        <div className="gh-connect">
          {oauth.configured && (
            <>
              <button className="btn btn-primary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 12 }} onClick={() => ghOAuthStart(oauth.clientId)}>
                <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
                Connect with GitHub
              </button>
              <div style={{ textAlign: 'center', fontSize: 11.5, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '0.1em', fontFamily: 'var(--mono)', margin: '4px 0 12px' }}>or paste a token</div>
            </>
          )}
          <div className="gh-instructions">
            <ol>
              <li>Visit <a href="https://github.com/settings/personal-access-tokens/new" target="_blank" rel="noopener">GitHub → fine-grained PAT</a></li>
              <li>Token name: <code>AISO</code> · Expiration: 90 days</li>
              <li>Repository access: <strong>Only select repositories</strong> — pick the repo for your site</li>
              <li>Permissions:
                <ul>
                  <li><strong>Contents: Read and write</strong></li>
                  <li><strong>Pull requests: Read and write</strong></li>
                  <li>Metadata: Read (auto)</li>
                </ul>
              </li>
              <li>Generate, copy the <code>github_pat_…</code> token, paste it below.</li>
            </ol>
          </div>
          <div className="gh-input-row">
            <div className="kr-input-wrap" style={{ flex: 1 }}>
              <input
                type={reveal ? 'text' : 'password'}
                className="kr-input"
                value={token}
                onChange={(e) => setToken(e.target.value)}
                placeholder="github_pat_…"
                autoComplete="off"
                spellCheck={false}
              />
              {token && (
                <button className="kr-reveal" onClick={() => setReveal(!reveal)}>
                  {reveal ? <EyeOff /> : <Eye />}
                </button>
              )}
            </div>
            <button className="btn btn-primary" onClick={connect} disabled={!token.trim() || busy}>
              {busy ? 'Connecting…' : 'Connect'}
            </button>
          </div>
          {error && <div className="gh-error">{error}</div>}
        </div>
      )}

      {connected && (
        <div className="gh-connected">
          {!repoOnly && (
          <div className="gh-user">
            <img src={state.user.avatar} alt="" className="gh-avatar" />
            <div>
              <div className="gh-username">@{state.user.login}</div>
              <div className="gh-subtle">{state.user.name}</div>
            </div>
            <button className="btn-ghost" onClick={disconnect}>Disconnect</button>
          </div>
          )}

          <div className="gh-repo-section">
            <div className="gh-repo-head">
              <h4>{repoOnly ? 'Repo for this site' : 'Active repo'}</h4>
              {!repos && <button className="btn" onClick={loadRepoList} disabled={busy}>{busy ? 'Loading…' : 'Browse repos'}</button>}
            </div>

            {repo && (
              <div className="gh-repo-selected">
                <div>
                  <div className="gh-repo-name">{repo.fullName}</div>
                  <div className="gh-repo-meta">
                    {repo.private ? '🔒 private' : 'public'} · default: <code>{repo.defaultBranch}</code>
                    {repo.language && <> · {repo.language}</>}
                  </div>
                </div>
                <div style={{ display: 'flex', gap: 8 }}>
                  <button className="btn-ghost" onClick={() => { if (onPickRepo) onPickRepo(null); else saveGh({ repo: null }); setState(loadGh()); loadRepoList(); }}>Change</button>
                  <button className="btn-ghost" onClick={() => { if (onPickRepo) onPickRepo(null); else saveGh({ repo: null }); setState(loadGh()); setRepos(null); }}>Remove</button>
                </div>
              </div>
            )}

            {!repo && repos && (
              <>
                <input
                  type="text"
                  value={search}
                  onChange={(e) => setSearch(e.target.value)}
                  placeholder="Search repos…"
                  className="gh-search"
                />
                <div className="gh-repo-list">
                  {filtered.slice(0, 50).map(r => {
                    const usedBy = usedElsewhere.get(r.fullName);
                    return (
                      <button key={r.fullName} className="gh-repo-row" onClick={() => { if (!usedBy) pickRepo(r); }} disabled={!!usedBy} style={usedBy ? { opacity: 0.5, cursor: 'not-allowed' } : undefined}>
                        <div>
                          <div className="gh-repo-name">{r.fullName}</div>
                          <div className="gh-repo-meta">
                            {usedBy
                              ? <span style={{ color: 'var(--warn, #e0a050)' }}>already connected to {usedBy}</span>
                              : <>{r.private ? '🔒' : ''} {r.language ? r.language : ''} · pushed {timeAgo(r.pushedAt)}{r.description && <span style={{ marginLeft: 8, opacity: 0.7 }}>{r.description.slice(0, 80)}</span>}</>}
                          </div>
                        </div>
                        <span style={{ color: 'var(--fg-3)' }}>{usedBy ? '✕' : '→'}</span>
                      </button>
                    );
                  })}
                </div>
              </>
            )}

            {repo && (
              <div className="gh-ready">
                <span className="db-dot live-pulse" />
                <strong>Wired.</strong> When you click "Approve & deploy" on a fix, AISO will open a real PR on <code>{repo.fullName}</code>.
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

function timeAgo(iso) {
  const ms = Date.now() - new Date(iso).getTime();
  const days = Math.floor(ms / 86400000);
  if (days < 1) return 'today';
  if (days === 1) return 'yesterday';
  if (days < 30) return `${days}d ago`;
  if (days < 365) return `${Math.floor(days / 30)}mo ago`;
  return `${Math.floor(days / 365)}y ago`;
}

Object.assign(window, {
  ghLoad: loadGh,
  ghSave: saveGh,
  ghClear: clearGh,
  ghTestToken: testToken,
  ghListRepos: listRepos,
  ghOpenFixPR: openFixPR,
  ghResolveTarget: resolveHeadInjection,
  ghFetchPrFiles,
  ghMergePR,
  ghRevertFix,
  ghVerifyLive,
  ghListOpenPrs,
  ghListPrs,
  ghClosePr,
  ghOAuthConfig,
  ghOAuthStart,
  ghOAuthExchange,
  GitHubConnector,
});
