/**
 * Workflow Marketplace — sync recipes + pipelines from one or more Git
 * repos. Mirrors the connector marketplace's multi-source model:
 *
 *   Priority 0..N-1: Enterprise repos (the SAME repos used by connector
 *     sync — each enterprise registry.json declares its connectors AND
 *     its workflow templates). Workflow side reads the recipes/pipelines
 *     arrays out of the cache connector sync already wrote.
 *
 *   Priority N (lowest): Public — settings.workflowRepoUrl (default
 *     aiwatching/forge-workflow). Fetched via raw.githubusercontent.com.
 *
 * Repo layout (same in public and enterprise):
 *
 *   registry.json
 *     { version, recipes: [...], pipelines: [...], (connectors: [...])  }
 *   recipes/<name>/recipe.yaml
 *   recipes/<name>/info.json (optional)
 *   pipelines/<name>/pipeline.yaml
 *   pipelines/<name>/info.json (optional)
 *
 * Local cache: <dataDir>/workflow-cache.json — public registry only.
 * Enterprise registries live in the connector cache; we read them via
 * `readSourceRegistry` from lib/connectors/sync.ts. Installed status
 * is derived by scanning files on disk under <dataDir>/recipes/ and
 * <dataDir>/flows/ (handled by lib/jobs/recipes.ts and lib/pipeline.ts).
 */

import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import YAML from 'yaml';
import { getDataDir } from './dirs';
import { loadSettings } from './settings';
import { listRecipes } from './jobs/recipes';
import { listWorkflows } from './pipeline';
import {
  listEnterpriseSourceMetas,
  readSourceRegistry,
  fetchSourceFile,
  type SourceMeta,
  type RegistryFile,
} from './connectors/sync';

export type WorkflowKind = 'recipe' | 'pipeline';

export interface MarketplaceEntry {
  kind: WorkflowKind;
  name: string;
  display_name: string;
  description?: string;
  version: string;
  author?: string;
  tags?: string[];
  score?: number;
  rating?: number;
  source: 'registry' | 'local';
  installed: boolean;
  installed_version?: string;
  has_update?: boolean;
  /** Which marketplace source provided the winning entry. */
  source_id?: string;
  /** Other sources that also list this name but were outranked. */
  hidden_sources?: { source_id: string; display_name: string; version: string }[];
}

const CACHE_FILE = () => join(getDataDir(), 'workflow-cache.json');
const REQUEST_TIMEOUT_MS = 10_000;

const DEFAULT_PUBLIC_REPO = 'https://raw.githubusercontent.com/aiwatching/forge-workflow/main';

function publicBaseUrl(): string {
  return (loadSettings().workflowRepoUrl || '').trim().replace(/\/+$/, '') || DEFAULT_PUBLIC_REPO;
}

/** Public workflow source meta — separate repo from connectors, so we
 *  build it locally rather than reusing the connector sync's `public`. */
function publicWorkflowSource(priority: number): SourceMeta {
  return {
    id: 'public',
    display_name: 'Public',
    is_enterprise: false,
    priority,
    fetch_mode: 'raw',
    base_url: publicBaseUrl(),
  };
}

/** Ordered list of workflow sources, highest priority first. */
function listSources(): SourceMeta[] {
  const enterprise = listEnterpriseSourceMetas();
  return [...enterprise, publicWorkflowSource(enterprise.length)];
}

// ─── Public cache I/O ──────────────────────────────────────

function readPublicCache(): RegistryFile | null {
  try {
    const path = CACHE_FILE();
    if (!existsSync(path)) return null;
    return JSON.parse(readFileSync(path, 'utf-8'));
  } catch { return null; }
}

function writePublicCache(data: RegistryFile): void {
  mkdirSync(getDataDir(), { recursive: true });
  writeFileSync(CACHE_FILE(), JSON.stringify(data, null, 2), { mode: 0o600 });
}

function readSourceData(source: SourceMeta): RegistryFile | null {
  if (source.is_enterprise) {
    // Piggyback on connector sync's per-source cache — enterprise
    // registry.json contains recipes/pipelines alongside connectors.
    return readSourceRegistry(source.id);
  }
  return readPublicCache();
}

// ─── Sync ──────────────────────────────────────────────────

/**
 * Refresh the workflow registry for every source whose registry we
 * own. Enterprise registries are owned by the connector sync — those
 * are pulled in `lib/connectors/sync.ts#syncRegistry`. This function
 * just refreshes the public workflow registry; the UI typically
 * triggers both syncs back-to-back.
 */
export async function syncMarketplace(): Promise<{ ok: boolean; recipes: number; pipelines: number; error?: string }> {
  const base = publicBaseUrl();
  if (!base) return { ok: false, recipes: 0, pipelines: 0, error: 'workflowRepoUrl is empty' };
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const cacheBust = `_t=${Date.now()}`;
    const res = await fetch(`${base}/registry.json?${cacheBust}`, {
      signal: controller.signal,
      headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
    });
    clearTimeout(timer);
    if (!res.ok) return { ok: false, recipes: 0, pipelines: 0, error: `registry fetch failed: HTTP ${res.status}` };
    const data = (await res.json()) as RegistryFile;
    writePublicCache(data);
    console.log(`[workflow-marketplace] public synced ${data.recipes?.length ?? 0} recipes + ${data.pipelines?.length ?? 0} pipelines`);
    return { ok: true, recipes: data.recipes?.length ?? 0, pipelines: data.pipelines?.length ?? 0 };
  } catch (e) {
    clearTimeout(timer);
    const msg = (e as Error).message || String(e);
    console.warn(`[workflow-marketplace] sync failed:`, msg);
    return { ok: false, recipes: 0, pipelines: 0, error: msg };
  }
}

// ─── List (merged across sources) ──────────────────────────

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

interface PendingEntry {
  winning_source: SourceMeta;
  raw: NonNullable<RegistryFile['recipes']>[number] | NonNullable<RegistryFile['pipelines']>[number];
  hidden: { source_id: string; display_name: string; version: string }[];
}

/** Walk sources in priority order; first occurrence wins, later ones
 *  collect into hidden_sources on the winning entry. */
function mergeAcrossSources(
  kindKey: 'recipes' | 'pipelines',
  sources: SourceMeta[],
): Map<string, PendingEntry> {
  const out = new Map<string, PendingEntry>();
  for (const source of sources) {
    const data = readSourceData(source);
    const items = (data?.[kindKey] || []) as NonNullable<RegistryFile['recipes']>;
    for (const item of items) {
      if (!item?.name) continue;
      const existing = out.get(item.name);
      if (!existing) {
        out.set(item.name, { winning_source: source, raw: item, hidden: [] });
      } else {
        existing.hidden.push({
          source_id: source.id,
          display_name: source.display_name,
          version: item.version || '0.0.0',
        });
      }
    }
  }
  return out;
}

function toEntry(kind: WorkflowKind, pe: PendingEntry): MarketplaceEntry {
  const r = pe.raw;
  return {
    kind,
    name: r.name,
    display_name: r.display_name || r.name,
    description: r.description,
    version: r.version || '0.0.0',
    author: r.author,
    tags: r.tags,
    score: r.score ?? 0,
    rating: r.rating ?? 0,
    source: 'registry',
    source_id: pe.winning_source.id,
    hidden_sources: pe.hidden.length ? pe.hidden : undefined,
    installed: false,
  };
}

export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: MarketplaceEntry[] } {
  const sources = listSources();
  const installedRecipes = listRecipes();
  const installedWorkflows = listWorkflows();

  // Recipes
  const recipeMap = new Map<string, MarketplaceEntry>();
  for (const [name, pe] of mergeAcrossSources('recipes', sources)) {
    recipeMap.set(name, toEntry('recipe', pe));
  }
  for (const ir of installedRecipes) {
    const cached = recipeMap.get(ir.name);
    if (cached) {
      cached.installed = true;
      cached.installed_version = ir.version;
      cached.has_update = compareVersions(cached.version, ir.version) > 0;
    }
    // local-only recipe — intentionally skipped (Marketplace = remote catalog)
  }

  // Pipelines
  const pipelineMap = new Map<string, MarketplaceEntry>();
  for (const [name, pe] of mergeAcrossSources('pipelines', sources)) {
    pipelineMap.set(name, toEntry('pipeline', pe));
  }
  for (const iw of installedWorkflows) {
    if (iw.builtin) continue;
    const cached = pipelineMap.get(iw.name);
    if (cached) {
      cached.installed = true;
      cached.installed_version = (iw as any).version || cached.version;
    }
    // local-only pipeline — intentionally skipped
  }

  return {
    recipes: Array.from(recipeMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
    pipelines: Array.from(pipelineMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
  };
}

// ─── Install ───────────────────────────────────────────────

/** Find the highest-priority source that lists `name` for `kind`. */
function findEntrySource(kind: WorkflowKind, name: string): SourceMeta | null {
  const kindKey = kind === 'recipe' ? 'recipes' : 'pipelines';
  for (const source of listSources()) {
    const data = readSourceData(source);
    const items = (data?.[kindKey] || []) as Array<{ name?: string }>;
    if (items.some(i => i?.name === name)) return source;
  }
  return null;
}

/** Try a few candidate paths inside a source — `<kind>/<name>/<kind>.yaml`
 *  first (canonical), then the flat `<kind>/<name>.yaml` fallback. */
async function fetchWorkflowYaml(source: SourceMeta, kind: WorkflowKind, name: string): Promise<string | null> {
  const subdir = kind === 'recipe' ? 'recipes' : 'pipelines';
  const file = kind === 'recipe' ? 'recipe.yaml' : 'pipeline.yaml';
  const candidates = [`${subdir}/${name}/${file}`, `${subdir}/${name}.yaml`];
  for (const path of candidates) {
    try {
      const text = await fetchSourceFile(source, path);
      if (text) return text;
    } catch {
      // try next candidate
    }
  }
  return null;
}

export async function installFromMarketplace(
  kind: WorkflowKind,
  name: string,
  opts: { target_name?: string; overwrite?: boolean } = {},
): Promise<{ ok: boolean; error?: string; installed_as?: string; source_id?: string }> {
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
  const targetName = opts.target_name || name;
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(targetName)) {
    return { ok: false, error: `target_name "${targetName}" must be lowercase alphanumerics + hyphens/underscores` };
  }

  const source = findEntrySource(kind, name);
  if (!source) return { ok: false, error: `${kind} "${name}" not found in registry — try Sync` };

  const yamlText = await fetchWorkflowYaml(source, kind, name);
  if (!yamlText) return { ok: false, error: `${kind} "${name}" file not reachable in ${source.id}` };

  // Validate it parses + has the right shape before writing.
  let parsed: any;
  try {
    parsed = YAML.parse(yamlText);
    if (!parsed?.name) return { ok: false, error: 'yaml missing top-level "name"' };
    if (parsed.name !== name) return { ok: false, error: `yaml name "${parsed.name}" doesn't match registry id "${name}"` };
    if (kind === 'recipe') {
      if (!Array.isArray(parsed.params)) return { ok: false, error: 'recipe yaml missing "params" array' };
      if (!parsed.job) return { ok: false, error: 'recipe yaml missing "job" block' };
    }
  } catch (e) {
    return { ok: false, error: `invalid YAML: ${(e as Error).message}` };
  }

  const targetDir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
  const targetPath = join(targetDir, `${targetName}.yaml`);
  if (existsSync(targetPath) && !opts.overwrite) {
    return { ok: false, error: `"${targetName}" already exists locally — pass overwrite=true to replace it, or choose a different name` };
  }

  // Rename rewrites the yaml's top-level `name:` so the on-disk copy
  // matches its filename. Without this the file would be loaded under
  // the original name and collide with other clones.
  let outputYaml = yamlText;
  if (targetName !== name) {
    parsed.name = targetName;
    outputYaml = YAML.stringify(parsed);
  }
  writeFileSync(targetPath, outputYaml, { mode: 0o600 });
  return { ok: true, installed_as: targetName, source_id: source.id };
}

// ─── Uninstall ─────────────────────────────────────────────

export function uninstallFromMarketplace(kind: WorkflowKind, name: string): { ok: boolean; error?: string } {
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
  const dir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
  const yamlPath = join(dir, `${name}.yaml`);
  const ymlPath = join(dir, `${name}.yml`);
  if (existsSync(yamlPath)) { unlinkSync(yamlPath); return { ok: true }; }
  if (existsSync(ymlPath)) { unlinkSync(ymlPath); return { ok: true }; }
  return { ok: false, error: 'not installed' };
}
