/**
 * Connector Registry — load, install, uninstall, list connectors.
 *
 * Manifests live in `<dataDir>/connectors/<id>/manifest.yaml`, fetched
 * by lib/connectors/sync.ts from the remote forge-connectors repo.
 * There are no built-in connector manifests — a fresh install has zero
 * connectors until the first registry sync completes.
 *
 * Per-instance config (settings filled in by the user — host URL,
 * PAT, etc.) lives in `<dataDir>/connector-configs.json`. Secret
 * fields are encrypted at rest with AES-256-GCM via lib/crypto.ts.
 */

import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import YAML from 'yaml';
import { getDataDir } from '../dirs';
import { encryptSecret, decryptSecret, isEncrypted } from '../crypto';
import type {
  ConnectorDefinition,
  ConnectorEntry,
  ConnectorFieldSchema,
} from './types';

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

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

function manifestPath(id: string): string {
  return join(connectorsDir(), id, 'manifest.yaml');
}

function configsFile(): string {
  return join(getDataDir(), 'connector-configs.json');
}

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

// ─── Manifest loading ─────────────────────────────────────

function parseManifest(raw: string): ConnectorDefinition | null {
  try {
    const def = YAML.parse(raw) as ConnectorDefinition;
    if (!def?.id || !def?.name) return null;
    if (!def.tools && !def.connectors?.length) return null;
    if (!def.version) def.version = '0.0.0';
    if (!def.icon) def.icon = '🔌';
    return def;
  } catch {
    return null;
  }
}

/** Read the manifest for a single connector id; returns null if missing. */
export function getConnector(id: string): ConnectorDefinition | null {
  const p = manifestPath(id);
  if (!existsSync(p)) return null;
  try {
    return parseManifest(readFileSync(p, 'utf-8'));
  } catch {
    return null;
  }
}

/** List installed connector ids (presence of `<dir>/manifest.yaml`). */
export function listConnectorIds(): string[] {
  const dir = connectorsDir();
  if (!existsSync(dir)) return [];
  const out: string[] = [];
  for (const name of readdirSync(dir)) {
    try {
      const sub = join(dir, name);
      if (!statSync(sub).isDirectory()) continue;
      if (existsSync(join(sub, 'manifest.yaml'))) out.push(name);
    } catch {}
  }
  return out;
}

/**
 * Normalize a connector to its entry list.
 *   1:1 shape (top-level `tools`) → synthesizes one entry with id === connector id.
 *   1:N shape (`connectors[]`) → returned as-is.
 */
export function getConnectorEntries(def: ConnectorDefinition): ConnectorEntry[] {
  if (def.connectors?.length) return def.connectors;
  if (def.tools) {
    return [
      {
        id: def.id,
        tools: def.tools,
        settings: def.settings,
        host_match: def.host_match,
        login_redirect: def.login_redirect,
        runner: def.runner,
      },
    ];
  }
  return [];
}

// ─── Config store ─────────────────────────────────────────

/**
 * Wire shape of `<dataDir>/connector-configs.json`:
 *   { "<id>": { config, installed_version, enabled, installed_source_id } }
 *
 * `installed_source_id` records which marketplace source the on-disk
 * manifest came from — `public`, `enterprise-<tenant_id>`, or undefined
 * for install-local / pre-enterprise rows. UI uses it for the source
 * badge; sync.ts updates it on every refresh so it stays in step with
 * the priority-winning source.
 */
interface ConnectorConfigRow {
  config: Record<string, unknown>;
  installed_version: string;
  enabled?: boolean;
  installed_source_id?: string;
  /** E3: which department template last wrote this row. Future
   *  wizard re-runs default the dept picker to this value so a user
   *  on FortiNAC doesn't get bumped to FortiWeb when they re-open
   *  the wizard. */
  installed_dept?: string;
}

type ConfigStore = Record<string, ConnectorConfigRow>;

function getSecretFieldNames(def: ConnectorDefinition | null): string[] {
  if (!def) return [];
  const fields: Array<[string, ConnectorFieldSchema]> = [];
  if (def.settings) fields.push(...Object.entries(def.settings));
  for (const entry of def.connectors || []) {
    if (entry.settings) fields.push(...Object.entries(entry.settings));
  }
  return fields
    .filter(([, schema]) => {
      const t = String((schema as any)?.type || '');
      return t === 'secret' || t === 'password';
    })
    .map(([name]) => name);
}

function getAllSettingsSchema(def: ConnectorDefinition | null): Record<string, ConnectorFieldSchema> {
  const out: Record<string, ConnectorFieldSchema> = {};
  if (!def) return out;
  if (def.settings) Object.assign(out, def.settings);
  for (const entry of def.connectors || []) {
    if (entry.settings) Object.assign(out, entry.settings);
  }
  return out;
}

/**
 * Walk a connector config value applying `op` (encrypt | decrypt) to
 * every leaf whose schema is `type: 'secret'` or `'password'`. Recurses
 * into `type: 'instances'` arrays so per-row sub-secrets (e.g. Jenkins
 * api_token inside each instances row) get the same treatment as flat
 * top-level secrets — otherwise nested credentials stay plaintext at
 * rest. The on-disk shape is preserved: instances stay as a JSON-
 * stringified array (which is how the type:instances UI persists it).
 */
function transformConnectorSecrets(
  value: any,
  schema: ConnectorFieldSchema | undefined,
  op: 'encrypt' | 'decrypt',
): any {
  if (!schema) return value;
  const t = String((schema as any)?.type || '');

  if (t === 'secret' || t === 'password') {
    if (typeof value !== 'string' || !value) return value;
    // Template tokens (`{base_url}`, `{connector:gitlab.token}`,
    // `{user.login}`, `{settings.x}`, `{secret:scope.path}`) are literal
    // references resolved at request time by expandAllTokens. Encrypting
    // them would (a) hide the indirection from users browsing their
    // connector settings, and (b) treat the *resolved* value as
    // plaintext on decrypt. Skip the encrypt step for any value that
    // looks like a single token reference (`{[A-Za-z0-9_.:]+}`).
    // Real PATs / api-tokens never contain `{` or `}`, so the false-
    // positive risk on a legitimate secret value is zero.
    if (op === 'encrypt' && /^\{[A-Za-z0-9_.:]+\}$/.test(value.trim())) {
      return value;
    }
    if (op === 'encrypt') return isEncrypted(value) ? value : (() => {
      try { return encryptSecret(value); } catch { return value; }
    })();
    if (op === 'decrypt') {
      if (!isEncrypted(value)) return value;
      try { return decryptSecret(value); }
      catch { return value; }
    }
    return value;
  }

  if (t === 'instances' && (schema as any).fields) {
    // Accept either the stored JSON-string form or an in-memory array.
    let rows: any;
    const wasString = typeof value === 'string';
    if (wasString) {
      try { rows = JSON.parse(value); } catch { return value; }
    } else {
      rows = value;
    }
    if (!Array.isArray(rows)) return value;
    const transformed = rows.map((row: any) => {
      if (!row || typeof row !== 'object') return row;
      const out: any = { ...row };
      for (const [k, sub] of Object.entries((schema as any).fields)) {
        out[k] = transformConnectorSecrets(out[k], sub as ConnectorFieldSchema, op);
      }
      return out;
    });
    return wasString ? JSON.stringify(transformed) : transformed;
  }

  return value;
}

function loadStoreRaw(): ConfigStore {
  const p = configsFile();
  if (!existsSync(p)) return {};
  try {
    return JSON.parse(readFileSync(p, 'utf-8')) as ConfigStore;
  } catch {
    return {};
  }
}

function loadStore(): ConfigStore {
  const store = loadStoreRaw();
  // Decrypt secret fields lazily — readers expect plaintext. Walks the
  // schema so both flat top-level secrets AND nested instances sub-
  // secrets get unwrapped uniformly.
  for (const [id, row] of Object.entries(store)) {
    if (!row?.config) continue;
    const def = getConnector(id);
    const schema = getAllSettingsSchema(def);
    for (const [k, sub] of Object.entries(schema)) {
      try { row.config[k] = transformConnectorSecrets(row.config[k], sub, 'decrypt'); }
      catch (err) { console.warn(`[connectors] failed to decrypt ${id}.${k}`, err); }
    }
  }
  return store;
}

function saveStore(store: ConfigStore): void {
  ensureDir(getDataDir());
  // Deep-copy so we can encrypt without mutating caller-visible values.
  const out: ConfigStore = {};
  for (const [id, row] of Object.entries(store)) {
    const def = getConnector(id);
    const schema = getAllSettingsSchema(def);
    const encryptedConfig: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(row.config || {})) {
      const sub = schema[k];
      encryptedConfig[k] = sub
        ? transformConnectorSecrets(v, sub, 'encrypt')
        : v;
    }
    out[id] = {
      config: encryptedConfig,
      installed_version: row.installed_version,
      enabled: row.enabled !== false,
      installed_source_id: row.installed_source_id,
    };
  }
  writeFileSync(configsFile(), JSON.stringify(out, null, 2), { mode: 0o600 });
}

// ─── Public API ───────────────────────────────────────────

export interface InstalledConnector {
  definition: ConnectorDefinition;
  config: Record<string, unknown>;
  installed_version: string;
  enabled: boolean;
  /** Marketplace source that provided the on-disk manifest:
   *  `public` | `enterprise-<tenant_id>` | undefined for install-local
   *  or pre-enterprise rows. */
  installed_source_id?: string;
  /** E3: department name (matches template `_department_name`) the
   *  row was last applied under. Used by the wizard to default the
   *  dept picker to whatever the user last chose. */
  installed_dept?: string;
}

export function isInstalled(id: string): boolean {
  return existsSync(manifestPath(id)) && !!loadStoreRaw()[id];
}

/**
 * IDs that have a config row but no manifest on disk yet. Happens
 * during the migration window after upgrading from pre-v0.9 Forge —
 * the row is moved across, but the manifest can only be fetched
 * from the remote registry. Sync uses this to know what to pull.
 */
export function listConfigOnlyIds(): string[] {
  const store = loadStoreRaw();
  const out: string[] = [];
  for (const id of Object.keys(store)) {
    if (!existsSync(manifestPath(id))) out.push(id);
  }
  return out;
}

export function listInstalledConnectors(): InstalledConnector[] {
  const store = loadStore();
  const out: InstalledConnector[] = [];
  for (const id of listConnectorIds()) {
    const def = getConnector(id);
    if (!def) continue;
    const row = store[id];
    if (!row) continue; // manifest on disk but no config row → not installed
    out.push({
      definition: def,
      config: applyLegacyConfigAliases(id, row.config || {}),
      installed_version: row.installed_version || def.version,
      enabled: row.enabled !== false,
      installed_source_id: row.installed_source_id,
      installed_dept: row.installed_dept,
    });
  }
  return out;
}

export function getInstalledConnector(id: string): InstalledConnector | null {
  const def = getConnector(id);
  if (!def) return null;
  const row = loadStore()[id];
  if (!row) return null;
  const config = applyLegacyConfigAliases(id, row.config || {});
  return {
    definition: def,
    config,
    installed_version: row.installed_version || def.version,
    enabled: row.enabled !== false,
    installed_source_id: row.installed_source_id,
    installed_dept: row.installed_dept,
  };
}

/**
 * Per-connector backwards-compat for setting renames. Lets us evolve
 * default_<param> keys (e.g. `default_project` → `default_project_name`
 * for mantis v0.27.0) without forcing every existing user to re-fill
 * the wizard. New name wins when both are set.
 */
function applyLegacyConfigAliases(id: string, config: Record<string, unknown>): Record<string, unknown> {
  if (id === 'mantis' && config.default_project && !config.default_project_name) {
    // Rename (not duplicate): leaving the legacy key around lets the
    // dispatcher's pass-2 stem-match still fire it into project_id,
    // which is a number param Mantis can't resolve from a string.
    const { default_project, ...rest } = config;
    return { ...rest, default_project_name: default_project };
  }
  return config;
}

/**
 * Install (or upgrade) a connector by writing its manifest YAML and
 * recording the version in the config store. Existing user config is
 * preserved across upgrades.
 *
 * `opts.source_id` records which marketplace source provided this
 * manifest. Sync passes the priority-winning source id; install-local
 * leaves it undefined. Preserves the previous source_id when undefined
 * is passed so a manual re-install doesn't accidentally orphan the
 * source tracking.
 */
export function installConnector(
  id: string,
  manifestYaml: string,
  opts: { source_id?: string } = {},
): boolean {
  const def = parseManifest(manifestYaml);
  if (!def || def.id !== id) return false;
  const dir = join(connectorsDir(), id);
  ensureDir(dir);
  writeFileSync(join(dir, 'manifest.yaml'), manifestYaml, { mode: 0o600 });

  const store = loadStore();
  const prev = store[id];
  store[id] = {
    config: prev?.config || {},
    installed_version: def.version,
    enabled: true,
    installed_source_id: opts.source_id !== undefined ? opts.source_id : prev?.installed_source_id,
  };
  saveStore(store);
  return true;
}

/**
 * Uninstall a connector.
 *   keepConfig=true (default): drop the manifest but retain settings/PAT
 *     so re-installing later restores them.
 *   keepConfig=false: also delete the config row.
 */
export function uninstallConnector(id: string, keepConfig = true): boolean {
  const dir = join(connectorsDir(), id);
  if (existsSync(dir)) {
    try { rmSync(dir, { recursive: true, force: true }); }
    catch { return false; }
  }
  if (!keepConfig) {
    const store = loadStore();
    delete store[id];
    saveStore(store);
  } else {
    // Mark disabled so the runtime doesn't try to use a now-missing manifest.
    const store = loadStore();
    if (store[id]) {
      store[id].enabled = false;
      saveStore(store);
    }
  }
  return true;
}

export function setConnectorConfig(
  id: string,
  config: Record<string, unknown>,
): boolean {
  const store = loadStore();
  const row = store[id];
  if (!row) {
    // Allow setting config before install if a manifest already exists
    // — supports auto-install-then-configure flows in the UI.
    const def = getConnector(id);
    if (!def) return false;
    store[id] = { config, installed_version: def.version, enabled: true };
  } else {
    store[id] = { ...row, config };
  }
  saveStore(store);
  // Creds may have changed — drop any cached probe so the Login Status
  // panel re-probes on next view. Dynamic import to avoid pulling the
  // auth subsystem (and its disk I/O) into every registry consumer.
  import('../auth/login-status').then(m => m.invalidateCachedResult(`connector:${id}`)).catch(() => {});
  return true;
}

/** E3: stamp the dept that owns this connector's last applied config.
 *  Called by applyConnectors when the template carries _department_name.
 *  Removing the dept = pass undefined; reading is via the underlying
 *  store row. */
export function setConnectorDept(id: string, dept: string | undefined): boolean {
  const store = loadStore();
  if (!store[id]) return false;
  if (dept && dept.trim()) store[id].installed_dept = dept.trim();
  else delete store[id].installed_dept;
  saveStore(store);
  return true;
}

export function setConnectorEnabled(id: string, enabled: boolean): boolean {
  const store = loadStore();
  if (!store[id]) return false;
  store[id].enabled = enabled;
  saveStore(store);
  return true;
}

// Re-export for callers (matches plugin-registry surface so it's familiar).
export { getSecretFieldNames };
