/**
 * Connector sync — pull registry + manifests from forge-connectors.
 *
 * Default base URL: https://raw.githubusercontent.com/aiwatching/forge-connectors/main
 * Override via settings.connectorsRepoUrl.
 *
 * The remote repo is expected to expose:
 *   /registry.json       — listing of all available connectors (light)
 *   /<id>/manifest.yaml  — the full YAML for one connector
 *
 * Local state:
 *   <dataDir>/connectors/registry-cache.json — last successful fetch
 *   <dataDir>/connectors/<id>/manifest.yaml  — per-connector manifest
 *     (installed copy, refreshed on update)
 *
 * Sync is called non-blocking on startup and on-demand from the
 * Settings Marketplace UI. Network failures are silent — callers
 * keep the cached state. No built-in fallback: if the cache is empty
 * and the network is down, the marketplace is just empty.
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadSettings } from '../settings';
import { getDataDir } from '../dirs';
import { getConnector, installConnector, listConfigOnlyIds, listInstalledConnectors } from './registry';
import type { ConnectorMarketEntry } from './types';

const DEFAULT_REPO = 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main';
const FETCH_TIMEOUT_MS = 10_000;

interface RegistryFile {
  version?: number;
  connectors?: Array<{
    id: string;
    name: string;
    version: string;
    icon?: string;
    description?: string;
    author?: string;
    min_forge_version?: string;
    /** Override manifest path; defaults to `<id>/manifest.yaml`. */
    manifest?: string;
  }>;
}

interface RegistryCache {
  fetched_at: string;
  base_url: string;
  data: RegistryFile;
}

// ─── Paths ────────────────────────────────────────────────

function connectorsDir(): string {
  return join(getDataDir(), 'connectors');
}

function cacheFile(): string {
  return join(connectorsDir(), 'registry-cache.json');
}

function ensureDir(p: string): void {
  if (!existsSync(p)) mkdirSync(p, { recursive: true, mode: 0o700 });
}

function baseUrl(): string {
  return (loadSettings().connectorsRepoUrl || '').trim() || DEFAULT_REPO;
}

// ─── Cache I/O ────────────────────────────────────────────

export function readCache(): RegistryCache | null {
  const p = cacheFile();
  if (!existsSync(p)) return null;
  try {
    return JSON.parse(readFileSync(p, 'utf-8')) as RegistryCache;
  } catch {
    return null;
  }
}

function writeCache(cache: RegistryCache): void {
  ensureDir(connectorsDir());
  writeFileSync(cacheFile(), JSON.stringify(cache, null, 2), { mode: 0o600 });
}

// ─── Fetch helpers ────────────────────────────────────────

async function fetchText(url: string): Promise<string> {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
  try {
    const r = await fetch(url, {
      signal: ctrl.signal,
      headers: {
        // Some CDN edges (cloudflare in front of raw.githubusercontent.com)
        // refuse default Node UA — set an explicit one.
        'User-Agent': 'forge-connectors-sync/1.0',
        'Cache-Control': 'no-cache',
      },
    });
    if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
    return await r.text();
  } catch (err) {
    // undici wraps the real failure under `err.cause`. Surface it so
    // the user gets a useful message instead of a bare "fetch failed".
    const e = err as Error & { cause?: unknown };
    const cause = e.cause instanceof Error ? `: ${e.cause.message}` : e.cause ? `: ${String(e.cause)}` : '';
    throw new Error(`${e.message}${cause} (${url})`);
  } finally {
    clearTimeout(t);
  }
}

async function fetchJson<T>(url: string): Promise<T> {
  const txt = await fetchText(url);
  return JSON.parse(txt) as T;
}

function cacheBust(): string {
  return `?_t=${Date.now()}`;
}

// ─── Sync API ─────────────────────────────────────────────

export interface SyncResult {
  ok: boolean;
  registry_count: number;
  manifests_refreshed: number;
  error?: string;
  fetched_at?: string;
}

/**
 * Pull the registry.json and refresh on-disk manifests for any
 * connector the user has installed (so install_version stays accurate
 * and scripts get bug fixes). For not-yet-installed entries we only
 * cache the registry row — the manifest is pulled on demand at install
 * time (see installFromRegistry).
 */
export async function syncRegistry(opts: { refreshInstalled?: boolean } = {}): Promise<SyncResult> {
  const base = baseUrl();
  console.log(`[connectors] Syncing registry from ${base}`);
  let registry: RegistryFile;
  try {
    registry = await fetchJson<RegistryFile>(`${base}/registry.json${cacheBust()}`);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    console.warn(`[connectors] registry fetch failed: ${msg}`);
    return { ok: false, registry_count: 0, manifests_refreshed: 0, error: msg };
  }

  const entries = registry.connectors || [];
  const cache: RegistryCache = {
    fetched_at: new Date().toISOString(),
    base_url: base,
    data: registry,
  };
  writeCache(cache);

  let refreshed = 0;
  if (opts.refreshInstalled) {
    // Two cases we want to pull manifests for:
    //   1. local manifest exists but version is behind the registry
    //   2. config row exists but no manifest on disk yet (post-migration
    //      from pre-v0.9 plugin-configs.json — we have the user's PAT
    //      but the manifest must come from the remote)
    const configOnly = new Set(listConfigOnlyIds());
    for (const e of entries) {
      const local = getConnector(e.id);
      const isConfigOnly = configOnly.has(e.id);
      if (!local && !isConfigOnly) continue;
      if (local && local.version === e.version) continue;
      try {
        const yamlText = await fetchManifest(e.id, e.manifest);
        installConnector(e.id, yamlText);
        refreshed += 1;
      } catch (err) {
        console.warn(`[connectors] refresh failed for ${e.id}:`, err);
      }
    }
  }

  return {
    ok: true,
    registry_count: entries.length,
    manifests_refreshed: refreshed,
    fetched_at: cache.fetched_at,
  };
}

/** Fetch one connector's manifest from the configured repo. */
export async function fetchManifest(id: string, manifestPath?: string): Promise<string> {
  const base = baseUrl();
  const path = manifestPath || `${id}/manifest.yaml`;
  return fetchText(`${base}/${path}${cacheBust()}`);
}

/**
 * Install a connector by id — looks it up in the registry cache (or
 * fetches a fresh one), pulls the manifest, and hands off to the
 * registry. Returns the chosen version on success.
 */
export async function installFromRegistry(id: string): Promise<{ ok: boolean; version?: string; error?: string }> {
  let cache = readCache();
  if (!cache) {
    const r = await syncRegistry();
    if (!r.ok) return { ok: false, error: r.error || 'registry unavailable' };
    cache = readCache();
  }
  const entry = cache?.data.connectors?.find((c) => c.id === id);
  if (!entry) return { ok: false, error: `${id} not in registry` };
  try {
    const yamlText = await fetchManifest(id, entry.manifest);
    const ok = installConnector(id, yamlText);
    return ok ? { ok: true, version: entry.version } : { ok: false, error: 'manifest invalid' };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

// ─── Marketplace listing ──────────────────────────────────

/**
 * Merge the registry cache with installed state to produce the
 * marketplace view. Pure read; no network.
 *
 * If the registry cache is missing (first boot, offline, sync failed)
 * we still surface locally-installed connectors so the user sees
 * something — labelled as installed without a remote counterpart.
 * Refresh button is the path back to a real registry listing.
 */
export function listMarketplace(): {
  fetched_at?: string;
  base_url?: string;
  entries: ConnectorMarketEntry[];
} {
  const cache = readCache();
  const installed = listInstalledConnectors();
  const installedById = new Map(installed.map((i) => [i.definition.id, i] as const));

  const entries: ConnectorMarketEntry[] = [];

  if (cache?.data.connectors?.length) {
    for (const c of cache.data.connectors) {
      const local = installedById.get(c.id);
      entries.push({
        id: c.id,
        name: c.name,
        version: c.version,
        icon: c.icon,
        description: c.description,
        author: c.author,
        installed_version: local?.installed_version,
        update_available: !!local && compareVersions(c.version, local.installed_version) > 0,
        compatible: isVersionCompatible(c.min_forge_version),
        source: 'registry',
      });
      installedById.delete(c.id);
    }
  }

  // Locally-installed connectors with no registry counterpart — either
  // installed via /api/connectors/install-local, or the registry is
  // currently unreachable. Either way: show them with source=local so
  // the UI suppresses the Update path.
  for (const local of installedById.values()) {
    const def = local.definition;
    entries.push({
      id: def.id,
      name: def.name,
      version: local.installed_version,
      icon: def.icon,
      description: def.description,
      author: def.author,
      installed_version: local.installed_version,
      update_available: false,
      compatible: true,
      source: 'local',
    });
  }

  return { fetched_at: cache?.fetched_at, base_url: cache?.base_url, entries };
}

// ─── Version helpers ──────────────────────────────────────

function compareVersions(a: string, b: string): number {
  const pa = (a || '0.0.0').split('.').map((n) => Number(n) || 0);
  const pb = (b || '0.0.0').split('.').map((n) => Number(n) || 0);
  for (let i = 0; i < 3; i++) {
    const d = (pa[i] || 0) - (pb[i] || 0);
    if (d !== 0) return d;
  }
  return 0;
}

function isVersionCompatible(min?: string): boolean {
  if (!min) return true;
  // Soft check — we don't ship a hard FORGE_VERSION constant on the
  // server; rely on the user keeping Forge current.
  // TODO: read version from package.json at boot if we want to enforce.
  return true;
}
