/**
 * browser-skills — storage helpers for per-task Playwright scripts.
 *
 * A browser-skill is a directory containing SKILL.md (frontmatter + prose),
 * script.ts (deterministic Playwright-via-browse-client script), an _lib/
 * with a copy of the SDK, fixtures/ for tests, and script.test.ts.
 *
 * Three tiers, walked in order project > global > bundled (first-wins):
 *   project:  <project>/.gstack/browser-skills/<name>/
 *   global:   ~/.gstack/browser-skills/<name>/
 *   bundled:  <gstack-install>/browser-skills/<name>/   (read-only, ships with gstack)
 *
 * No INDEX.json. `listBrowserSkills()` walks the three directories every call
 * (~5-10ms for 50 skills, invisible). Eliminates a whole class of "index
 * drifted from disk" bugs.
 *
 * Tombstones move a skill to `<tier>/.tombstones/<name>-<ts>/` so the user
 * can recover. `$B skill list` ignores tombstoned directories.
 *
 * Zero side effects on import. Safe to import from tests.
 */

import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as cp from 'child_process';

// ─── Types ──────────────────────────────────────────────────────

export type SkillTier = 'project' | 'global' | 'bundled';

/** Required + optional fields from a browser-skill SKILL.md frontmatter. */
export interface SkillFrontmatter {
  /** Skill name; must match the directory name. */
  name: string;
  /** One-line description (optional but recommended). */
  description?: string;
  /** Primary hostname this skill targets, e.g. "news.ycombinator.com". */
  host: string;
  /** Trigger phrases the resolver matches against ("scrape hn frontpage"). */
  triggers: string[];
  /**
   * Args the script accepts (passed via `$B skill run <name> --arg key=value`).
   * Phase 1 keeps this loose: each arg is just a name and optional description.
   */
  args: SkillArg[];
  /**
   * Trust flag. true = full env passed to spawn (human-authored, audited).
   * false (default) = scrubbed env, locked cwd. Orthogonal to scoped-token
   * capabilities: untrusted skills still get a read+write daemon token.
   */
  trusted: boolean;
  /** Optional semver-ish version string for skill upgrades. */
  version?: string;
  /** Whether the skill was hand-written or generated by the skillify flow. */
  source?: 'human' | 'agent';
}

export interface SkillArg {
  name: string;
  description?: string;
}

export interface BrowserSkill {
  name: string;
  tier: SkillTier;
  /** Absolute path to the skill directory. */
  dir: string;
  frontmatter: SkillFrontmatter;
  /** SKILL.md prose body (everything after the frontmatter block). */
  bodyMd: string;
}

export interface TierPaths {
  /** May be null in non-project contexts (e.g. tests, standalone runs). */
  project: string | null;
  global: string;
  bundled: string;
}

// ─── Tier resolution ────────────────────────────────────────────

/**
 * Resolve the three tier directories from runtime context.
 * Project tier requires git or a project hint; returns null when neither resolves.
 */
export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths {
  const home = opts.home ?? os.homedir();
  const projectRoot = opts.projectRoot ?? detectProjectRoot();
  const bundledRoot = opts.bundledRoot ?? detectBundledRoot();

  return {
    project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null,
    global: path.join(home, '.gstack', 'browser-skills'),
    bundled: path.join(bundledRoot, 'browser-skills'),
  };
}

function detectProjectRoot(): string | null {
  try {
    const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 });
    if (proc.status === 0) {
      const out = proc.stdout.trim();
      return out || null;
    }
  } catch {}
  return null;
}

function detectBundledRoot(): string {
  // The browse binary lives at <gstack-install>/browse/dist/browse.
  // The bundled browser-skills/ dir is a sibling of browse/ (i.e. <gstack-install>/browser-skills/).
  // For dev/source runs, process.execPath is bun itself — fall back to the source-tree
  // directory two levels up from this file.
  try {
    const exec = process.execPath;
    if (exec && /\/browse\/dist\/browse$/.test(exec)) {
      return path.resolve(path.dirname(exec), '..', '..');
    }
  } catch {}
  // Source/dev fallback: walk up from this file's dir to a directory that has both browse/ and browser-skills/.
  // browse/src/browser-skills.ts → ../../  (the gstack root).
  return path.resolve(__dirname, '..', '..');
}

// ─── Frontmatter parsing ────────────────────────────────────────

/**
 * Parse a SKILL.md into { frontmatter, bodyMd }. Throws if the file is
 * missing required fields (host, triggers, args).
 */
export function parseSkillFile(content: string, opts: { skillName?: string } = {}): { frontmatter: SkillFrontmatter; bodyMd: string } {
  if (!content.startsWith('---\n')) {
    throw new Error('SKILL.md missing frontmatter block (expected starting "---\\n")');
  }
  const fmEnd = content.indexOf('\n---', 4);
  if (fmEnd === -1) {
    throw new Error('SKILL.md frontmatter block not terminated (expected "\\n---")');
  }
  const fmText = content.slice(4, fmEnd);
  const bodyMd = content.slice(fmEnd + 4).replace(/^\n+/, '');
  const fm = parseFrontmatterFields(fmText);

  // Validate required fields.
  const errors: string[] = [];
  const name = fm.name ?? opts.skillName ?? '';
  if (!name) errors.push('missing required field: name (or skillName hint)');
  if (!fm.host) errors.push('missing required field: host');
  // triggers and args may be omitted — empty list is valid.
  if (errors.length > 0) {
    throw new Error(`SKILL.md validation failed: ${errors.join('; ')}`);
  }

  const frontmatter: SkillFrontmatter = {
    name,
    description: fm.description,
    host: fm.host as string,
    triggers: Array.isArray(fm.triggers) ? fm.triggers : [],
    args: Array.isArray(fm.args) ? fm.args : [],
    trusted: fm.trusted === true,
    version: typeof fm.version === 'string' ? fm.version : undefined,
    source: fm.source === 'agent' || fm.source === 'human' ? fm.source : undefined,
  };

  return { frontmatter, bodyMd };
}

interface RawFrontmatter {
  name?: string;
  description?: string;
  host?: string;
  triggers?: string[];
  args?: SkillArg[];
  trusted?: boolean;
  version?: string;
  source?: string;
}

/**
 * Tiny frontmatter parser tuned for the browser-skill subset:
 *   - simple key: value scalars
 *   - YAML list: `key:\n  - item1\n  - item2`
 *   - args list of mappings: `args:\n  - name: foo\n    description: bar`
 *
 * Quoting: a value wrapped in "..." or '...' is taken literally (handles colons).
 * Anything more exotic should use a real YAML library — not in Phase 1 scope.
 */
function parseFrontmatterFields(fm: string): RawFrontmatter {
  const result: RawFrontmatter = {};
  const lines = fm.split('\n');
  let i = 0;

  while (i < lines.length) {
    const line = lines[i];

    // Skip blank lines and comments
    if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }

    // Top-level scalar: `key: value`
    const scalar = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
    if (scalar && !line.startsWith(' ')) {
      const key = scalar[1];
      const rawVal = scalar[2];

      // Empty value: list or mapping follows on next lines
      if (!rawVal) {
        // Peek to determine list vs unset
        const nextNonBlank = findNextNonBlank(lines, i + 1);
        if (nextNonBlank !== -1 && lines[nextNonBlank].match(/^\s+-\s/)) {
          // List — collect items
          if (key === 'args') {
            const { items, consumed } = collectArgsList(lines, i + 1);
            (result as any)[key] = items;
            i += 1 + consumed;
          } else {
            const { items, consumed } = collectStringList(lines, i + 1);
            (result as any)[key] = items;
            i += 1 + consumed;
          }
          continue;
        }
        i++;
        continue;
      }

      // Inline list: `key: []`
      if (rawVal === '[]') {
        (result as any)[key] = [];
        i++;
        continue;
      }

      // Inline scalar
      (result as any)[key] = parseScalar(rawVal);
      i++;
      continue;
    }

    i++;
  }

  return result;
}

function findNextNonBlank(lines: string[], from: number): number {
  for (let i = from; i < lines.length; i++) {
    if (lines[i].trim()) return i;
  }
  return -1;
}

function collectStringList(lines: string[], from: number): { items: string[]; consumed: number } {
  const items: string[] = [];
  let i = from;
  while (i < lines.length) {
    const line = lines[i];
    if (!line.trim()) { i++; continue; }
    const m = line.match(/^\s+-\s+(.*)$/);
    if (!m) break;
    items.push(stripQuotes(m[1]));
    i++;
  }
  return { items, consumed: i - from };
}

function collectArgsList(lines: string[], from: number): { items: SkillArg[]; consumed: number } {
  const items: SkillArg[] = [];
  let i = from;
  while (i < lines.length) {
    const line = lines[i];
    if (!line.trim()) { i++; continue; }
    // Item start: `  - name: foo` (with whatever indent)
    const itemStart = line.match(/^(\s+)-\s+(.+?):\s*(.*)$/);
    if (!itemStart) break;
    const indent = itemStart[1] + '  '; // continuation lines get 2 more spaces
    const arg: SkillArg = { name: '' };
    if (itemStart[2] === 'name') {
      arg.name = stripQuotes(itemStart[3]);
    } else if (itemStart[2] === 'description') {
      arg.description = stripQuotes(itemStart[3]);
    }
    i++;
    // Read continuation lines `    description: ...`
    while (i < lines.length) {
      const cont = lines[i];
      if (!cont.startsWith(indent) || !cont.trim()) break;
      const kv = cont.match(/^\s+([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
      if (!kv) break;
      if (kv[1] === 'name') arg.name = stripQuotes(kv[2]);
      else if (kv[1] === 'description') arg.description = stripQuotes(kv[2]);
      i++;
    }
    items.push(arg);
  }
  return { items, consumed: i - from };
}

function parseScalar(raw: string): string | boolean | number {
  const v = raw.trim();
  if (v === 'true') return true;
  if (v === 'false') return false;
  if (/^-?\d+$/.test(v)) return parseInt(v, 10);
  return stripQuotes(v);
}

function stripQuotes(v: string): string {
  const trimmed = v.trim();
  if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
      (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
    return trimmed.slice(1, -1);
  }
  return trimmed;
}

// ─── Listing + reading ──────────────────────────────────────────

/**
 * Walk all three tiers and return every visible skill (tombstones excluded).
 * Tier precedence: project > global > bundled. If the same skill name appears
 * in multiple tiers, the entry from the highest-priority tier wins.
 */
export function listBrowserSkills(tiers?: TierPaths): BrowserSkill[] {
  const t = tiers ?? defaultTierPaths();
  const seen = new Map<string, BrowserSkill>();

  // Walk in priority order: project first, so it wins over global/bundled.
  const order: Array<{ tier: SkillTier; root: string | null }> = [
    { tier: 'project', root: t.project },
    { tier: 'global', root: t.global },
    { tier: 'bundled', root: t.bundled },
  ];

  for (const { tier, root } of order) {
    if (!root || !fs.existsSync(root)) continue;
    let entries: string[];
    try { entries = fs.readdirSync(root); } catch { continue; }
    for (const entry of entries) {
      if (entry.startsWith('.') || entry === '.tombstones') continue;
      if (seen.has(entry)) continue; // higher-priority tier already claimed this name
      const dir = path.join(root, entry);
      let stat: fs.Stats;
      try { stat = fs.statSync(dir); } catch { continue; }
      if (!stat.isDirectory()) continue;

      const skillFile = path.join(dir, 'SKILL.md');
      if (!fs.existsSync(skillFile)) continue;

      try {
        const content = fs.readFileSync(skillFile, 'utf-8');
        const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: entry });
        seen.set(entry, { name: entry, tier, dir, frontmatter, bodyMd });
      } catch {
        // Malformed skill — skip silently. listBrowserSkills is best-effort;
        // skill-validation tests catch these at build time.
        continue;
      }
    }
  }

  return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
}

/**
 * Read a single skill by name (first-tier-wins). Returns null if not found
 * in any tier.
 */
export function readBrowserSkill(name: string, tiers?: TierPaths): BrowserSkill | null {
  const t = tiers ?? defaultTierPaths();
  const order: Array<{ tier: SkillTier; root: string | null }> = [
    { tier: 'project', root: t.project },
    { tier: 'global', root: t.global },
    { tier: 'bundled', root: t.bundled },
  ];

  for (const { tier, root } of order) {
    if (!root) continue;
    const dir = path.join(root, name);
    const skillFile = path.join(dir, 'SKILL.md');
    if (!fs.existsSync(skillFile)) continue;

    try {
      const content = fs.readFileSync(skillFile, 'utf-8');
      const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: name });
      return { name, tier, dir, frontmatter, bodyMd };
    } catch {
      // Malformed — try next tier.
      continue;
    }
  }

  return null;
}

// ─── Tombstone (rm) ─────────────────────────────────────────────

/**
 * Move a user-tier skill (project or global) into the tier's .tombstones/
 * directory. Returns the new path.
 *
 * Cannot tombstone bundled skills — they ship with gstack and are read-only.
 * To remove a bundled skill, override it with a global/project entry, or
 * remove the file from the gstack source tree.
 */
export function tombstoneBrowserSkill(name: string, tier: 'project' | 'global', tiers?: TierPaths): string {
  const t = tiers ?? defaultTierPaths();
  const root = tier === 'project' ? t.project : t.global;
  if (!root) {
    throw new Error(`tombstoneBrowserSkill: tier "${tier}" has no resolved path`);
  }
  const src = path.join(root, name);
  if (!fs.existsSync(src)) {
    throw new Error(`tombstoneBrowserSkill: skill "${name}" not found in tier "${tier}" at ${src}`);
  }
  const tombstoneDir = path.join(root, '.tombstones');
  fs.mkdirSync(tombstoneDir, { recursive: true });
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  const dst = path.join(tombstoneDir, `${name}-${ts}`);
  fs.renameSync(src, dst);
  return dst;
}
