import { readFile, writeFile, mkdir, symlink, rename, unlink, readlink } from 'node:fs/promises';
import { join, dirname, basename } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
  blueGreenSlotEntryPoint,
  blueGreenSlotReady,
  findPackageRepoDir,
  isDkgMonorepoRoot,
  resolveDkgConfigHome,
} from '@origintrail-official/dkg-core';

/**
 * Per-step build timeouts (milliseconds) used by the git-based auto-update
 * path. Slow hosts (notably ARM64 nodes compiling Solidity contracts via the
 * WASM solc fallback) can exceed the defaults; operators set overrides via
 * `~/.dkg/config.json` -> `autoUpdate.buildTimeoutMs`. All keys are optional;
 * unset keys fall back through network/<env>.json -> built-in defaults.
 */
export interface AutoUpdateBuildTimeouts {
  /** `pnpm install --frozen-lockfile` (default 180_000). */
  install?: number;
  /** `pnpm build:runtime` / `pnpm build` (default 180_000). */
  build?: number;
  /**
   * @deprecated Ignored since the auto-updater stopped invoking `hardhat
   * compile` on node hosts. Committed `packages/evm-module/abi/*.json` are
   * now the runtime contract surface, and CI enforces freshness (see
   * `abi-freshness` job in `.github/workflows/ci.yml`). The field is
   * retained on the type so existing user configs don't fail to parse.
   */
  contracts?: number;
  /** MarkItDown bundling step (default 900_000). */
  markitdown?: number;
}

export interface AutoUpdateConfig {
  enabled: boolean;
  /** Optional in ~/.dkg/config.json: omit to inherit from network/project config. */
  repo?: string;
  /** Optional in ~/.dkg/config.json: omit to inherit from network/project config. */
  branch?: string;
  /** Allow auto-updating to pre-release versions (e.g. 9.0.5-rc.1). */
  allowPrerelease?: boolean;
  /** Optional SSH private key path for git-based update fetches/clones. */
  sshKeyPath?: string;
  /** Optional raw GIT_SSH_COMMAND override for git-based update fetches/clones. */
  sshCommand?: string;
  /**
   * Optional in ~/.dkg/config.json: omit to inherit from network/project config.
   * `resolveAutoUpdateConfig()` falls back to network -> 30 (minutes).
   */
  checkIntervalMinutes?: number;
  /** Optional per-step build timeout overrides for the git-based update path. */
  buildTimeoutMs?: AutoUpdateBuildTimeouts;
}

/**
 * AutoUpdateConfig with `repo` and `branch` guaranteed present — the shape
 * returned by `resolveAutoUpdateConfig()` after falling back through
 * ~/.dkg/config.json -> network/<env>.json -> project.json. Consumers of the
 * auto-update subsystem should accept this type, not the raw `AutoUpdateConfig`,
 * since the raw form allows `repo`/`branch` to be omitted.
 */
export type ResolvedAutoUpdateConfig = AutoUpdateConfig & {
  repo: string;
  branch: string;
  checkIntervalMinutes: number;
};

export interface NetworkConfig {
  networkName: string;
  networkId: string;
  genesisVersion: number;
  relays: string[];
  /** V10: context graphs */
  defaultContextGraphs?: string[];
  defaultNodeRole: 'core' | 'edge';
  autoUpdate?: {
    enabled: boolean;
    repo: string;
    branch: string;
    allowPrerelease?: boolean;
    sshKeyPath?: string;
    sshCommand?: string;
    checkIntervalMinutes: number;
    buildTimeoutMs?: AutoUpdateBuildTimeouts;
  };
  chain?: {
    type: 'evm';
    rpcUrl: string;
    hubAddress: string;
    tokenAddress?: string;
    chainId: string;
  };
  faucet?: {
    url: string;
    mode: string;
  };
  /**
   * Maintainer-controlled marker bumped on every chain reset (e.g. testnet
   * V10 staking consolidation, fresh contract redeploy with state wipe).
   * The daemon's chain-reset-wipe hook compares this against the last value
   * persisted in `<dataDir>/.network-state.json`; on mismatch it auto-wipes
   * `store.nq` + `publish-journal.*` + `random-sampling.wal` so the node
   * boots clean against the new chain. Operators do nothing.
   *
   * Distinct from `networkId` which is a SHA256 of the bundled genesis
   * TriG and only changes when the genesis itself does — chain redeploys
   * happen far more often than that, so they need a separate marker.
   *
   * Free-form string, suggested format: `<purpose>-<yyyy-mm-dd>` (e.g.
   * `v10-rs-staking-consolidation-2026-04-30`). Maintainer bumps in the
   * same commit that captures the post-deploy state.
   */
  chainResetMarker?: string;
}

export interface ChainConfig {
  /** 'evm' for real blockchain, omit or 'mock' for in-memory (testing only) */
  type: 'evm' | 'mock';
  /** JSON-RPC endpoint URL */
  rpcUrl: string;
  /** Hub contract address */
  hubAddress: string;
  /** Optional token contract address override. When omitted, resolve from Hub.Token. */
  tokenAddress?: string;
  /** Chain identifier (e.g., 'base:84532') */
  chainId?: string;
  /**
   * Test-only: when using `type: "mock"`, force the daemon's signer address to map
   * to this identity ID so private participant flows can be exercised from black-box CLI tests.
   */
  mockIdentityId?: string;
}

export interface LargeLiteralStorageConfig {
  enabled?: boolean;
  thresholdBytes?: number;
  directory?: string;
}

export interface SharedMemoryPublicSnapshotStorageConfig {
  enabled?: boolean;
  directory?: string;
}

/** Optional LLM config for the Node UI chatbot (OpenAI-compatible API). */
export interface LlmConfig {
  /** API key (e.g. OpenAI, Anthropic, or compatible provider). */
  apiKey: string;
  /** Model name (default: gpt-4o-mini). */
  model?: string;
  /** Base URL for the API (default: https://api.openai.com/v1). */
  baseURL?: string;
}

export type LocalAgentIntegrationStatus =
  | 'disconnected'
  | 'configured'
  | 'connecting'
  | 'ready'
  | 'degraded'
  | 'error';

export interface LocalAgentIntegrationCapabilities {
  localChat?: boolean;
  chatAttachments?: boolean;
  connectFromUi?: boolean;
  installNode?: boolean;
  dkgPrimaryMemory?: boolean;
  wmImportPipeline?: boolean;
  nodeServedSkill?: boolean;
}

export interface LocalAgentIntegrationTransport {
  kind?: string;
  bridgeUrl?: string;
  gatewayUrl?: string;
  healthUrl?: string;
}

export interface LocalAgentIntegrationManifest {
  packageName?: string;
  version?: string;
  setupEntry?: string;
}

export interface LocalAgentIntegrationRuntime {
  status?: LocalAgentIntegrationStatus;
  ready?: boolean;
  lastError?: string | null;
  updatedAt?: string;
}

/**
 * Inbound chat authorisation policy. Layered on top of the existing
 * Ed25519 signature check on every libp2p chat message — this controls
 * *which* authenticated peers we're willing to talk to, not *whether*
 * they're authenticated.
 *
 * Modes:
 *   - `any` — accept all authenticated peers (legacy behaviour). Only
 *     appropriate on a closed dev network where every peer is trusted.
 *   - `peer-allowlist` — only accept peerIds listed in `peerAllowlist`.
 *     Strongest, also most manual.
 *   - `scoped` (recommended Phase 1 default) — only accept peers whose
 *     node-principal is an `active` member of `contextGraphId`. Requires
 *     `contextGraphId` to be set; if it isn't, all inbound chats are
 *     rejected (fail-closed).
 *   - `shared-context-graph` — accept any peer that's an active
 *     node-member of at least one CG this node is also subscribed to.
 *     More flexible than `scoped`; less strict.
 *
 * Loopback (a node chatting itself) is always accepted, regardless of
 * mode — useful for local CLI testing and for the daemon's own
 * outbound→inbound debugging shortcuts.
 */
export type ChatAclMode = 'any' | 'peer-allowlist' | 'scoped' | 'shared-context-graph';

export interface ChatAclConfig {
  mode?: ChatAclMode;
  /** Required when `mode: 'scoped'`. */
  contextGraphId?: string;
  /** Required when `mode: 'peer-allowlist'`. */
  peerAllowlist?: string[];
}

export interface ChatConfig {
  /** Inbound chat authorisation policy. See {@link ChatAclConfig}. */
  acl?: ChatAclConfig;
}

export interface LocalAgentIntegrationConfig {
  id?: string;
  name?: string;
  description?: string;
  enabled?: boolean;
  transport?: LocalAgentIntegrationTransport;
  capabilities?: LocalAgentIntegrationCapabilities;
  manifest?: LocalAgentIntegrationManifest;
  setupEntry?: string;
  metadata?: Record<string, unknown>;
  runtime?: LocalAgentIntegrationRuntime;
  connectedAt?: string;
  updatedAt?: string;
}

export interface DkgConfig {
  name: string;
  relay?: string;
  apiPort: number;
  /** Host to bind the API server (default '127.0.0.1', use '0.0.0.0' for external access). */
  apiHost?: string;
  listenPort: number;
  nodeRole: 'core' | 'edge';
  /**
   * Core Node relay-server capacity tuning. Forwarded into the libp2p
   * relay configuration via `DKGNodeConfig.relayServerCapacity`. Sets
   * the maximum number of simultaneous circuit-relay v2 reservations
   * this node will hold; HOP/STOP stream caps and
   * `connectionManager.maxConnections` are derived at a 1:2 ratio
   * (capacity=1024 → 2048 streams + 2048 max conns).
   *
   * Default: 1024 when omitted on a Core Node. Ignored on edge nodes
   * (with a startup warning if set there). Invalid values (0,
   * negative, fractional, NaN) fall back to the default with a
   * warning. See packages/core/src/types.ts and packages/cli/README.md
   * for the full rationale + ulimit -n requirements.
   */
  relayServerCapacity?: number;
  /**
   * Number of relay reservations to hold in parallel when behind NAT.
   * Forwarded into `DKGNodeConfig.relayReservationCount`. Defaults to 3
   * when relayPeers are configured (N-2 tolerance to relay blackouts).
   * Capped at 16. Ignored (with a warning when set explicitly) in two
   * cases: no relayPeers configured, or the node itself runs a relay
   * server (core / `enableRelayServer: true` — relay servers don't
   * multi-reserve through other relays). Invalid values
   * (0/neg/NaN/fractional/non-numeric/over-cap) fall back to the
   * default with a warning. See packages/core/src/types.ts for the
   * full rationale.
   */
  relayReservationCount?: number;
  /**
   * Operator-preferred relay multiaddrs that take priority over the
   * network/<env>.json public relay set (rc.9 PR-7).
   *
   * When set, these multiaddrs are prepended to the active relayPeers
   * list at daemon startup so libp2p attempts reservations on them
   * first. The public testnet relays remain configured as fallback —
   * if an operator-relay disappears, the node continues to function
   * via the public set. Repeatable; populate from CLI via
   * `dkg start --relay-preferred <multiaddr>` (sets env var
   * `DKG_RELAY_PREFERRED` for the spawned daemon, comma-separated)
   * or write into `~/.dkg/config.json` for persistence.
   *
   * See `docs/messenger-operator.md` for the relay-setup playbook
   * (standing up a relay VM, sharing multiaddrs, monitoring).
   */
  preferredRelays?: string[];
  /** Public multiaddrs to announce (for VPS/cloud nodes where the public IP is not on the interface). */
  announceAddresses?: string[];
  /** Bootstrap peer multiaddrs to connect to on startup (for direct peer discovery without relay). */
  bootstrapPeers?: string[];
  /** V10: context graphs to subscribe. */
  contextGraphs?: string[];
  autoUpdate?: AutoUpdateConfig;
  /**
   * Chain config. Field-merged on top of `network/<env>.json#chain` via
   * `resolveChainConfig()`, so an operator can override individual fields
   * (e.g. just `rpcUrl` to point at a private RPC) without having to
   * restate `hubAddress` and `chainId`. Fields omitted here inherit the
   * network defaults — including future hub rotations propagated by the
   * auto-updater pulling a fresh network/<env>.json.
   */
  chain?: Partial<ChainConfig>;
  /** Optional LLM for the Node UI chatbot (natural language → SPARQL, answers). */
  llm?: LlmConfig;
  /** Block explorer URL for TX links (default: derived from chainId). */
  blockExplorerUrl?: string;
  /** Triple store backend override (default: oxigraph-worker with file persistence). */
  store?: { backend: string; options?: Record<string, unknown> };
  /** Out-of-line storage for large public SWM RDF literal object terms. */
  largeLiteralStorage?: LargeLiteralStorageConfig;
  /** Out-of-line storage for immutable public SWM operation snapshots. */
  sharedMemoryPublicSnapshotStorage?: SharedMemoryPublicSnapshotStorageConfig;
  /** Disable expensive peer-connect SWM catch-up for bulk benchmark/devnet runs. */
  syncSharedMemoryOnConnect?: boolean;
  /**
   * Generic local agent integration registry used by node-owned connect/install
   * flows. Framework-specific bridges (OpenClaw now, Hermes next) should store
   * status/capabilities here instead of relying on one-off config flags.
   */
  localAgentIntegrations?: Record<string, LocalAgentIntegrationConfig>;
  /**
   * API authentication. When enabled, all non-public endpoints require
   * a Bearer token in the Authorization header. A token is auto-generated
   * on first start and stored in `<DKG_HOME>/auth.token`.
   */
  auth?: { enabled?: boolean; tokens?: string[] };
  /** Opt-in telemetry streaming to central network dashboard. */
  telemetry?: { enabled?: boolean };
  /** Shared memory (workspace) data TTL in milliseconds. Default: 30 days (2592000000). Set to 0 to disable cleanup. */
  sharedMemoryTtlMs?: number;
  /** @deprecated Legacy alias for sharedMemoryTtlMs */
  workspaceTtlMs?: number;
  /** EPCIS plugin config. When set, POST /api/epcis/capture is enabled. */
  epcis?: { contextGraphId?: string };
  /** Async publisher runtime options. */
  publisher?: {
    enabled?: boolean;
    pollIntervalMs?: number;
    errorBackoffMs?: number;
    maxRetries?: number;
  };
  /** Allowed CORS origins. Defaults to '*' when apiHost is '127.0.0.1', otherwise restrictive. */
  corsOrigins?: string | string[];
  /** HTTP rate limiting settings. */
  rateLimit?: { requestsPerMinute?: number; exempt?: string[] };
  /**
   * V10 Random Sampling prover (core-only). When the node is `core`
   * AND has an on-chain identity, the agent automatically schedules
   * `RandomSamplingProver.tick()` on `tickIntervalMs`. Edge nodes
   * ignore this block. See `dkg-random-sampling` for the prover
   * itself; the bind layer is in `dkg-agent/random-sampling-bind.ts`.
   */
  randomSampling?: {
    /**
     * Persistent WAL path. When set, prover state transitions are
     * appended to this file (JSONL, fsync per write) for crash
     * recovery + `dkg rs wal-tail`. When unset, the prover uses an
     * in-memory WAL (test/dev only — production SHOULD set this).
     */
    walPath?: string;
    /**
     * Tick cadence in ms. Default 30_000. Set lower (e.g. 5_000) for
     * devnet smoke tests where you want the prover to react quickly
     * after a publish lands. The orchestrator is idempotent under
     * fast double-ticks (already-solved short-circuit).
     */
    tickIntervalMs?: number;
    /**
     * Default true on core. When false, the V10 Merkle build runs on
     * the agent's main thread. Useful in tests where spawning a
     * worker is undesirable.
     */
    useWorkerThread?: boolean;
  };
  /**
   * RFC 04 v0.3 / Issue #461 — opt this node into the Network State
   * Registry as a circuit-relay provider. When true, the daemon flips
   * the on-chain `relayCapable` flag on the node's profile so other
   * peers can resolve it as a candidate relay during the chain-driven
   * NetworkStateRegistry rollout (Phase 2+ via attestation KCs).
   *
   * Multiaddrs themselves are NOT published to chain via Profile
   * (RFC 04 §5.2 — they live in per-RS-round attestation KCs). This
   * flag is the operator's "I intend to run as a relay" hint that
   * gates whether they bother running the attestation cosig + submit
   * pipeline at all.
   *
   * Tri-state semantics on startup (Codex PR #506 fix):
   *   - true      → daemon ensures on-chain flag is true
   *   - false     → daemon ensures on-chain flag is false (actively
   *                 clears any stale prior opt-in)
   *   - undefined → daemon does not touch the on-chain flag, preserving
   *                 any manual `dkg admin set-relay-capable` flips
   *
   * Default: undefined (i.e. no chain interaction unless the operator
   * has set this explicitly in config). For a fresh node this is
   * equivalent to relay-incapable.
   */
  relayCapable?: boolean;
  /**
   * Agent-to-agent chat settings. Phase 1 (RFC: agent debug chat) only
   * uses the `acl` block, which controls who is allowed to send us
   * inbound chats over `/dkg/message/1.0.0`. Authentication is always
   * enforced by the protocol; this is the authorisation layer on top.
   * See {@link ChatConfig} / {@link ChatAclConfig}.
   */
  chat?: ChatConfig;
  /** Route-plugin specs (absolute paths / package names) loaded at daemon startup. ADR 0001. */
  routePlugins?: string[];
}

/**
 * Hardcoded per-network telemetry endpoints.
 * Nodes resolve the correct endpoints from the network they're on.
 * Operators only see a single toggle — no endpoint configuration.
 */
export const TELEMETRY_ENDPOINTS: Record<string, { syslog: { host: string; port: number }; otlp: string }> = {
  testnet: {
    syslog: { host: 'loggly.origin-trail.network', port: 12201 },
    otlp: 'https://telemetry-testnet.origintrail.io/v1/metrics',
  },
  mainnet: {
    syslog: { host: 'loggly.origin-trail.network', port: 0 }, // TODO: assign mainnet syslog port
    otlp: 'https://telemetry.origintrail.io/v1/metrics',
  },
};

const DEFAULT_CONFIG: DkgConfig = {
  name: 'dkg-node',
  apiPort: 9200,
  listenPort: 0,
  nodeRole: 'edge',
};

/** Resolve context graphs from config. */
export function resolveContextGraphs(config: DkgConfig): string[] {
  return config.contextGraphs ?? [];
}

/** Resolve context graphs from network config. */
export function resolveNetworkDefaultContextGraphs(network: NetworkConfig | null | undefined): string[] {
  return network?.defaultContextGraphs ?? [];
}

/** Resolve shared memory TTL from config, accepting both V10 and legacy keys. */
export function resolveSharedMemoryTtlMs(config: DkgConfig): number | undefined {
  return config.sharedMemoryTtlMs ?? config.workspaceTtlMs;
}

let _networkConfig: NetworkConfig | null = null;
let _networkConfigName: string | null = null;

export function _resetNetworkConfigCache(): void {
  _networkConfig = null;
  _networkConfigName = null;
}

export interface ProjectConfig {
  repo: string;
  defaultBranch: string;
  githubUrl: string;
  projectName: string;
  syslogAppName: string;
  defaultNetwork: string;
}

let _projectConfig: ProjectConfig | null = null;

/**
 * Return true when `dir` is the published DKG CLI package root, as
 * determined by an adjacent `package.json` whose `name` is
 * `@origintrail-official/dkg`. This is resilient to `projectName`
 * renames in `project.json` and cannot be spoofed by an unrelated app.
 */
function isDkgPackageRoot(dir: string): boolean {
  try {
    const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
    return pkg?.name === '@origintrail-official/dkg'
      && existsSync(join(dir, 'project.json'));
  } catch { return false; }
}

/**
 * Resolve candidate directories where repo-root files (project.json,
 * network/*.json) may live, in priority order.
 *
 * Ordering rationale:
 *   - In a monorepo checkout the DKG root is the single source of
 *     truth, so any detected monorepo ancestor MUST win. Otherwise,
 *     once `packages/cli/build` has copied `project.json` and
 *     `network/*.json` into `packages/cli/`, those stale artifacts
 *     would shadow edits made to the root files until the next
 *     rebuild — breaking the intended "edit root config, rerun" dev
 *     flow.
 *   - In a published npm install there is no monorepo ancestor, so
 *     we fall back to the package-local root (identified unambiguously
 *     by its `package.json.name`). This also guarantees we never
 *     accidentally read a consumer's own `project.json` from
 *     `node_modules/..`.
 */
function candidateRoots(): string[] {
  const thisDir = dirname(fileURLToPath(import.meta.url));
  const out: string[] = [];

  // Monorepo ancestors first (dev / source checkout). `dist/` and
  // `src/` live at different depths, so both paths are tried.
  const monorepoCandidates = [
    join(thisDir, '..', '..', '..'),        // from dist/
    join(thisDir, '..', '..', '..', '..'),  // from src/ during dev
  ];
  for (const dir of monorepoCandidates) {
    if (isDkgMonorepoRoot(dir)) out.push(dir);
  }

  // Package-local root as the fallback — the only location that ever
  // wins in a published install, and unambiguous via its package.json.
  const pkgRoot = join(thisDir, '..');
  if (isDkgPackageRoot(pkgRoot)) out.push(pkgRoot);

  return out;
}

/**
 * Load project.json — the single source of truth for repo name,
 * branch, GitHub URL, and default network. Values here drive the
 * startup banner, auto-update fallbacks, and network selection.
 *
 * To rename the repo or change the default branch/network, edit
 * project.json at the repo root — all runtime code follows.
 */
export function loadProjectConfig(): ProjectConfig {
  if (_projectConfig) return _projectConfig;
  for (const root of candidateRoots()) {
    try {
      const raw = readFileSync(join(root, 'project.json'), 'utf-8');
      _projectConfig = JSON.parse(raw) as ProjectConfig;
      return _projectConfig;
    } catch { /* try next */ }
  }
  _projectConfig = {
    repo: 'OriginTrail/dkg',
    defaultBranch: 'main',
    githubUrl: 'https://github.com/OriginTrail/dkg',
    projectName: 'dkg',
    syslogAppName: 'dkg',
    defaultNetwork: 'testnet',
  };
  return _projectConfig;
}

export function _resetProjectConfigCache(): void {
  _projectConfig = null;
}

/**
 * Field-level merge of the effective auto-update configuration.
 *
 * Precedence per field: `~/.dkg/config.json` → `network/<env>.json` →
 * `project.json` (or static fallback). Returns null when auto-update is
 * explicitly disabled in the local config.
 *
 * Rationale: `dkg init` intentionally omits `repo`/`branch` from the
 * persisted config when the user accepts the defaults, so that future
 * changes to the shipped network/project defaults propagate without a
 * config rewrite. Callers must therefore resolve the effective values
 * instead of reading `config.autoUpdate.repo` directly.
 */
export function resolveAutoUpdateConfig(
  config: Pick<DkgConfig, 'autoUpdate'> | null | undefined,
  network: Pick<NetworkConfig, 'autoUpdate'> | null | undefined,
): ResolvedAutoUpdateConfig | null {
  const cfg = config?.autoUpdate;
  const net = network?.autoUpdate;
  const enabled = cfg?.enabled ?? net?.enabled ?? false;
  if (!enabled) return null;

  const proj = loadProjectConfig();
  const repo = cfg?.repo ?? net?.repo ?? proj.repo;
  const branch = cfg?.branch ?? net?.branch ?? proj.defaultBranch;
  const allowPrerelease = cfg?.allowPrerelease ?? net?.allowPrerelease ?? true;
  const sshKeyPath = cfg?.sshKeyPath ?? net?.sshKeyPath;
  const sshCommand = cfg?.sshCommand ?? net?.sshCommand;
  const checkIntervalMinutes = cfg?.checkIntervalMinutes ?? net?.checkIntervalMinutes ?? 30;

  // Merge build timeouts per-key so operators can override one step (e.g.
  // `contracts` on slow ARM hosts) without re-specifying the rest.
  const buildTimeoutMs: AutoUpdateBuildTimeouts | undefined = (() => {
    const c = cfg?.buildTimeoutMs;
    const n = net?.buildTimeoutMs;
    if (!c && !n) return undefined;
    return {
      ...(c?.install ?? n?.install ? { install: c?.install ?? n?.install } : {}),
      ...(c?.build ?? n?.build ? { build: c?.build ?? n?.build } : {}),
      ...(c?.contracts ?? n?.contracts ? { contracts: c?.contracts ?? n?.contracts } : {}),
      ...(c?.markitdown ?? n?.markitdown ? { markitdown: c?.markitdown ?? n?.markitdown } : {}),
    };
  })();

  return {
    enabled: true,
    repo,
    branch,
    allowPrerelease,
    ...(sshKeyPath ? { sshKeyPath } : {}),
    ...(sshCommand ? { sshCommand } : {}),
    checkIntervalMinutes,
    ...(buildTimeoutMs ? { buildTimeoutMs } : {}),
  };
}

/**
 * Field-level merge of the effective chain configuration.
 *
 * Precedence per field: `~/.dkg/config.json#chain` → `network/<env>.json#chain`.
 * Returns `undefined` when neither source provides a chain block.
 *
 * Rationale: the daemon previously read chain via `config.chain ?? network.chain`
 * (whole-object fallback), which meant an operator who set just `rpcUrl` in their
 * local config would lose `hubAddress` / `chainId` from the network file and
 * crash deeper inside ethers. With per-field merge, operators can override
 * individual fields (e.g. swap public RPC for a private one) and still inherit
 * the rest — including future hub rotations the auto-updater pulls down.
 *
 * The return type is `Partial<ChainConfig>` because either source may be
 * partial; consumers that need both `rpcUrl` and `hubAddress` (lifecycle,
 * publisher-runner) MUST guard for those fields before passing to the agent.
 */
export function resolveChainConfig(
  config: Pick<DkgConfig, 'chain'> | null | undefined,
  network: Pick<NetworkConfig, 'chain'> | null | undefined,
): Partial<ChainConfig> | undefined {
  const cfg = config?.chain;
  const net = network?.chain;
  if (!cfg && !net) return undefined;

  // Short-circuit when the operator opts in to mock mode. We strip
  // rpcUrl/hubAddress entirely (both inherited-from-network AND any stale
  // values left over in the operator's own config from a previous EVM
  // setup) so no downstream consumer can hit a real chain by accident.
  // Without this, lifecycle.ts/publisher-runner.ts/status.ts/`dkg set-ask`
  // all gate on `rpcUrl && hubAddress` but not on `type`, so a hybrid
  // `{ type: 'mock', rpcUrl: '<real>', hubAddress: '<real>' }` would wire
  // up MockChainAdapter (correct) AND open a real ethers.JsonRpcProvider
  // / EVMChainAdapter against the live network in parallel. MockChainAdapter
  // only needs chainId; rpcUrl/hubAddress are meaningless in mock mode.
  if (cfg?.type === 'mock') {
    const mockMerged: Partial<ChainConfig> = { type: 'mock' };
    if (cfg.chainId !== undefined) mockMerged.chainId = cfg.chainId;
    if (cfg.mockIdentityId !== undefined) mockMerged.mockIdentityId = cfg.mockIdentityId;
    return mockMerged;
  }

  const merged: Partial<ChainConfig> = {
    type: cfg?.type ?? net?.type ?? 'evm',
  };
  const rpcUrl = cfg?.rpcUrl ?? net?.rpcUrl;
  if (rpcUrl !== undefined) merged.rpcUrl = rpcUrl;
  const hubAddress = cfg?.hubAddress ?? net?.hubAddress;
  if (hubAddress !== undefined) merged.hubAddress = hubAddress;
  const tokenAddress = cfg?.tokenAddress ?? net?.tokenAddress;
  if (tokenAddress !== undefined) merged.tokenAddress = tokenAddress;
  const chainId = cfg?.chainId ?? net?.chainId;
  if (chainId !== undefined) merged.chainId = chainId;
  if (cfg?.mockIdentityId !== undefined) merged.mockIdentityId = cfg.mockIdentityId;
  return merged;
}

/**
 * Load a network config from network/<name>.json.
 *
 * @param network - Network name (e.g. 'testnet', 'mainnet'). Defaults to
 *   the `defaultNetwork` value from project.json.
 *
 * Candidate paths (tried in order):
 *  1. Monorepo root when running from packages/cli/dist/
 *  2. Monorepo root when running from packages/cli/src/ during dev
 *  3. Bundled alongside dist/ in the published NPM package (dist/../network/)
 *
 * Monorepo paths are checked first so that edits to the repo-root
 * network/ files are picked up immediately during development
 * without requiring a rebuild of the CLI package.
 */
export async function loadNetworkConfig(network?: string): Promise<NetworkConfig | null> {
  const name = network ?? loadProjectConfig().defaultNetwork;
  if (_networkConfig && _networkConfigName === name) return _networkConfig;
  try {
    const file = `${name}.json`;
    const candidates = candidateRoots().map(root => join(root, 'network', file));
    for (const path of candidates) {
      try {
        const raw = await readFile(path, 'utf-8');
        _networkConfig = JSON.parse(raw) as NetworkConfig;
        _networkConfigName = name;
        return _networkConfig;
      } catch { /* try next */ }
    }
    return null;
  } catch {
    return null;
  }
}

export function dkgDir(): string {
  return resolveDkgConfigHome({ isDkgMonorepo: isDkgMonorepo() });
}

let _isDkgMonorepo: boolean | null = null;
export function isDkgMonorepo(): boolean {
  if (_isDkgMonorepo !== null) return _isDkgMonorepo;
  const root = repoDir();
  if (!root) { _isDkgMonorepo = false; return false; }
  _isDkgMonorepo = isDkgMonorepoRoot(root);
  return _isDkgMonorepo;
}

/**
 * Resolve the repo root from the compiled code location.
 * Works from packages/cli/dist/ (compiled) or packages/cli/src/ (dev).
 */
export function findRepoDir(startDir: string): string | null {
  return findPackageRepoDir(startDir);
}

export function repoDir(): string | null {
  return findRepoDir(dirname(fileURLToPath(import.meta.url)));
}

function shellQuote(value: string): string {
  return `'${value.replace(/'/g, `'"'"'`)}'`;
}

export function gitCommandEnv(autoUpdate?: Pick<AutoUpdateConfig, 'sshKeyPath' | 'sshCommand'> | null): NodeJS.ProcessEnv {
  const sshCommand = process.env.DKG_GIT_SSH_COMMAND?.trim() || autoUpdate?.sshCommand?.trim();
  const sshKeyPath = process.env.DKG_SSH_KEY_PATH?.trim() || autoUpdate?.sshKeyPath?.trim();
  const env = { ...process.env };

  if (sshCommand) {
    env.GIT_SSH_COMMAND = sshCommand;
    return env;
  }

  if (sshKeyPath) {
    env.GIT_SSH_COMMAND = `ssh -i ${shellQuote(sshKeyPath)} -o IdentitiesOnly=yes`;
  }

  return env;
}

function githubHttpsRepo(repoUrl: string | undefined): boolean {
  if (!repoUrl) return false;
  return /^https:\/\/github\.com\//i.test(repoUrl.trim());
}

export function gitCommandArgs(
  repoUrl?: string,
  autoUpdate?: Pick<AutoUpdateConfig, 'sshKeyPath' | 'sshCommand'> | null,
): string[] {
  const token = process.env.GITHUB_TOKEN?.trim();
  const sshCommand = process.env.DKG_GIT_SSH_COMMAND?.trim() || autoUpdate?.sshCommand?.trim();
  const sshKeyPath = process.env.DKG_SSH_KEY_PATH?.trim() || autoUpdate?.sshKeyPath?.trim();
  if (!token || sshCommand || sshKeyPath || !githubHttpsRepo(repoUrl)) return [];

  const basic = Buffer.from(`x-access-token:${token}`, 'utf-8').toString('base64');
  return ['-c', `http.extraHeader=Authorization: Basic ${basic}`];
}

export function releasesDir(): string {
  return join(dkgDir(), 'releases');
}

/** Read the active slot from the `releases/current` symlink. Falls back to the `active` file. */
export async function activeSlot(): Promise<'a' | 'b' | null> {
  try {
    const raw = await readlink(join(releasesDir(), 'current'));
    const target = basename(raw);
    if (target === 'a' || target === 'b') return target;
  } catch { /* symlink doesn't exist */ }
  try {
    const raw = (await readFile(join(releasesDir(), 'active'), 'utf-8')).trim();
    if (raw === 'a' || raw === 'b') return raw;
  } catch { /* file doesn't exist */ }
  return null;
}

export async function inactiveSlot(): Promise<'a' | 'b'> {
  const active = await activeSlot();
  return (active ?? 'a') === 'a' ? 'b' : 'a';
}

/**
 * Atomically swap the `releases/current` symlink to point to `target` slot.
 * Uses tmp-symlink + rename to avoid any window where the symlink is broken.
 */
export async function swapSlot(target: 'a' | 'b'): Promise<void> {
  const rDir = releasesDir();
  const currentLink = join(rDir, 'current');
  const tmpLink = join(rDir, 'current.tmp');

  // Check if already pointing to target
  try {
    const dest = await readlink(currentLink);
    if (dest === target) {
      await writeFile(join(rDir, 'active'), target);
      return;
    }
  } catch { /* link doesn't exist yet */ }

  try { await unlink(tmpLink); } catch { /* ok if missing */ }
  await symlink(target, tmpLink);
  await rename(tmpLink, currentLink);
  await writeFile(join(rDir, 'active'), target);
}

export function configPath(): string {
  return join(dkgDir(), 'config.json');
}

export function pidPath(): string {
  return join(dkgDir(), 'daemon.pid');
}

export function logPath(): string {
  return join(dkgDir(), 'daemon.log');
}

export function apiPortPath(): string {
  return join(dkgDir(), 'api.port');
}

export async function ensureDkgDir(): Promise<void> {
  await mkdir(dkgDir(), { recursive: true });
}

export async function loadConfig(): Promise<DkgConfig> {
  try {
    const raw = await readFile(configPath(), 'utf-8');
    return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
  } catch {
    return { ...DEFAULT_CONFIG };
  }
}

export async function saveConfig(config: DkgConfig): Promise<void> {
  await ensureDkgDir();
  await writeFile(configPath(), JSON.stringify(config, null, 2) + '\n');
}

export function configExists(): boolean {
  return existsSync(configPath());
}

export async function readPid(): Promise<number | null> {
  try {
    const raw = await readFile(pidPath(), 'utf-8');
    return parseInt(raw.trim(), 10);
  } catch {
    return null;
  }
}

export async function writePid(pid: number): Promise<void> {
  await writeFile(pidPath(), String(pid));
}

export async function removePid(): Promise<void> {
  const { unlink } = await import('node:fs/promises');
  try {
    await unlink(pidPath());
  } catch (err) {
    const code = err && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
    if (code !== 'ENOENT') throw err;
  }
}

export async function readApiPort(): Promise<number | null> {
  try {
    const raw = await readFile(apiPortPath(), 'utf-8');
    return parseInt(raw.trim(), 10);
  } catch {
    return null;
  }
}

export async function writeApiPort(port: number): Promise<void> {
  await writeFile(apiPortPath(), String(port));
}

export async function removeApiPort(): Promise<void> {
  const { unlink } = await import('node:fs/promises');
  try {
    await unlink(apiPortPath());
  } catch (err) {
    const code = err && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
    if (code !== 'ENOENT') throw err;
  }
}

export function isProcessRunning(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

// ─── NPM / standalone helpers ────────────────────────────────────────

export const CLI_NPM_PACKAGE = '@origintrail-official/dkg';

/**
 * True when running from an `npm install`-ed package rather than a
 * monorepo checkout. In standalone mode the auto-updater uses NPM
 * instead of git to fetch new versions.
 */
export function isStandaloneInstall(): boolean {
  return repoDir() === null;
}

/**
 * Resolve the CLI entry point within a blue-green slot.
 * Supports both git layout (packages/cli/dist/cli.js) and
 * NPM layout (node_modules/@origintrail-official/dkg/dist/cli.js).
 */
export function slotEntryPoint(slotDir: string): string | null {
  return blueGreenSlotEntryPoint(slotDir);
}

/** Return true when a blue-green slot has an entry point and install metadata. */
export function slotReady(slotDir: string): boolean {
  return blueGreenSlotReady(slotDir);
}
