import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';

/** Configuration for the mcplocal HTTP server. */
export interface HttpConfig {
  /** Port for the HTTP server (default: 3200) */
  httpPort: number;
  /** Host to bind to (default: 127.0.0.1) */
  httpHost: string;
  /** URL of the mcpd daemon (default: http://localhost:3100) */
  mcpdUrl: string;
  /** Bearer token for authenticating with mcpd */
  mcpdToken: string;
  /** Log level (default: info) */
  logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
}

const DEFAULT_HTTP_PORT = 3200;
const DEFAULT_HTTP_HOST = '127.0.0.1';
const DEFAULT_MCPD_URL = 'http://localhost:3100';
const DEFAULT_LOG_LEVEL = 'info';

/**
 * Read the user's mcpctl credentials from ~/.mcpctl/credentials.
 * Returns the token if found, empty string otherwise.
 */
function loadUserToken(): string {
  try {
    const credPath = join(homedir(), '.mcpctl', 'credentials');
    if (!existsSync(credPath)) return '';
    const raw = readFileSync(credPath, 'utf-8');
    const parsed = JSON.parse(raw) as { token?: string };
    return parsed.token ?? '';
  } catch {
    return '';
  }
}

export interface LlmFileConfig {
  provider: string;
  model?: string;
  url?: string;
  binaryPath?: string;
}

/** Multi-provider entry from config file. */
export interface LlmProviderFileEntry {
  name: string;
  type: string;
  model?: string;
  url?: string;
  binaryPath?: string;
  tier?: 'fast' | 'heavy';
  /** vllm-managed: path to Python venv (e.g. "~/vllm_env") */
  venvPath?: string;
  /** vllm-managed: port for vLLM HTTP server */
  port?: number;
  /** vllm-managed: GPU memory utilization fraction (0.1–1.0) */
  gpuMemoryUtilization?: number;
  /** vllm-managed: max model context length */
  maxModelLen?: number;
  /** vllm-managed: minutes of idle before stopping vLLM */
  idleTimeoutMinutes?: number;
  /** vllm-managed: extra args for `vllm serve` */
  extraArgs?: string[];
  /**
   * Persistent disable. When true:
   *   - mcplocal still instantiates the provider so `mcpctl provider enable`
   *     can flip it back live, but
   *   - `complete()` and `ensureRunning()` short-circuit with an error so
   *     the GPU process never spawns.
   * Toggled via `mcpctl provider {enable,disable} <name>`. Survives mcplocal
   * restarts because it lives here in the user's config file.
   */
  disabled?: boolean;
  /**
   * If set, this local provider is allowed to substitute for the centralized
   * Llm of this name when the mcpd inference proxy is unreachable.
   * RBAC is still enforced — the caller must have view permission on the
   * named Llm via mcpd before failover is permitted (fail-closed if mcpd
   * itself can't be reached).
   */
  failoverFor?: string;
  /**
   * Opt in to publishing this local provider into mcpd as a virtual Llm
   * row (see docs/virtual-llms.md). When true, the mcplocal registrar
   * announces the provider to mcpd at startup, opens an SSE control
   * channel, and relays inference tasks back to this local provider.
   * Default: false — existing setups don't change behavior.
   */
  publish?: boolean;
  /**
   * Optional wake recipe for hibernating backends. When set, a provider
   * whose `isAvailable()` returns false at registrar start time is
   * published as `status=hibernating`. The next inference request that
   * lands on the row triggers this recipe; mcplocal polls
   * `isAvailable()` until it returns true (or times out) and then flips
   * the row to active so mcpd can dispatch the queued inference.
   *
   * Two recipe types:
   *   - `http`: POST to a URL (e.g. an external sleep/wake controller)
   *   - `command`: spawn a shell command (e.g. `systemctl --user start vllm`)
   */
  wake?: WakeRecipe;
  /**
   * v4: opt this provider into a load-balanced pool. When set, the
   * published Llm row carries `poolName` and stacks with any other Llms
   * (public OR virtual) sharing the same value. Agents pinned to any
   * pool member dispatch across all healthy members at chat time.
   *
   * Convention for distributed-compute setups: each user's mcplocal
   * picks a unique `name` (e.g. `vllm-<hostname>-qwen3`) but a shared
   * `poolName` (e.g. `user-vllm-qwen3-thinking`). Result: agents see one
   * logical pool that auto-grows as more workers come online.
   */
  poolName?: string;
  /**
   * v7: per-user RBAC scoping. mcplocal-published virtuals default to
   * 'private' on register — the publishing user owns the row and other
   * users don't see it without an explicit `view:llms:<name>` grant.
   * Set to 'public' here to opt into org-wide sharing for this
   * provider.
   */
  visibility?: 'public' | 'private';
}

export type WakeRecipe =
  | { type: 'http'; url: string; method?: 'GET' | 'POST'; headers?: Record<string, string>; body?: string; maxWaitSeconds?: number }
  | { type: 'command'; command: string; args?: string[]; maxWaitSeconds?: number };

export interface ProjectLlmOverride {
  model?: string;
  provider?: string;
}

interface LlmMultiFileConfig {
  providers: LlmProviderFileEntry[];
}

/**
 * Local agent declaration (v3). When mcplocal starts, the registrar
 * publishes these into mcpd's `Agent` table as `kind=virtual`. They show
 * up under `mcpctl get agent` and become chat-able via `mcpctl chat <name>`.
 *
 * `llm` references a published provider's name from the `llm.providers`
 * array — the registrar resolves it server-side.
 */
export interface AgentFileEntry {
  name: string;
  llm: string;
  description?: string;
  systemPrompt?: string;
  project?: string;
  defaultParams?: Record<string, unknown>;
  extras?: Record<string, unknown>;
  /** v7: see LlmProviderFileEntry.visibility — same default ('private'). */
  visibility?: 'public' | 'private';
}

/**
 * v6: per-publisher namespacing config. When a `publisher.suffix` is set,
 * mcplocal appends `-<suffix>` to every published Llm/Agent name before
 * sending the register payload. Two mcplocals with distinct suffixes can
 * publish the same logical name (`vllm-local-qwen3` from a shared
 * config template) without colliding on the cluster-wide unique-name
 * constraint — they end up as `vllm-local-qwen3-alice` and
 * `vllm-local-qwen3-bob`. Pair with an explicit `poolName` (v4) and they
 * still appear as one logical pool.
 *
 * Set to `"auto"` to derive from the system hostname (sanitized to
 * `[a-z0-9-]`); explicit string values override. When the field is
 * absent or empty, no suffix is applied — this is fully backwards-
 * compatible with pre-v6 configs.
 */
export interface PublisherConfig {
  suffix?: string;
}

interface McpctlConfig {
  llm?: LlmFileConfig | LlmMultiFileConfig;
  agents?: AgentFileEntry[];
  projects?: Record<string, { llm?: ProjectLlmOverride }>;
  publisher?: PublisherConfig;
}

/** Cached config for the process lifetime (reloaded on SIGHUP if needed). */
let cachedConfig: McpctlConfig | null = null;

function loadFullConfig(): McpctlConfig {
  if (cachedConfig) return cachedConfig;
  try {
    const configPath = join(homedir(), '.mcpctl', 'config.json');
    if (!existsSync(configPath)) return {};
    const raw = readFileSync(configPath, 'utf-8');
    cachedConfig = JSON.parse(raw) as McpctlConfig;
    return cachedConfig;
  } catch {
    return {};
  }
}

/**
 * Persist a `disabled: bool` flag onto the named provider in
 * ~/.mcpctl/config.json. Used by the `mcpctl provider {enable,disable}`
 * route to make the change survive a mcplocal restart.
 *
 * Returns true when the file was actually rewritten (provider found and
 * its flag changed), false when the provider isn't in the config or the
 * flag already matches.
 */
export function setProviderDisabledInConfig(name: string, disabled: boolean): boolean {
  const configPath = join(homedir(), '.mcpctl', 'config.json');
  if (!existsSync(configPath)) return false;
  const raw = readFileSync(configPath, 'utf-8');
  // Round-trip via JSON.parse → mutate → JSON.stringify. We don't try to
  // preserve comments or formatting because the config file has always
  // been plain JSON (no JSONC) and the alternative — a structural editor
  // — is a lot of code for a feature toggle.
  const parsed = JSON.parse(raw) as McpctlConfig;
  if (parsed.llm === undefined || !isMultiConfig(parsed.llm)) return false;
  const entry = parsed.llm.providers.find((p) => p.name === name);
  if (entry === undefined) return false;
  const current = entry.disabled === true;
  if (current === disabled) return false;
  if (disabled) entry.disabled = true; else delete entry.disabled;
  writeFileSync(configPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
  // Invalidate the cached config so subsequent loadLlmProviders() calls
  // see the change. (Boot-time only, but cheap and correct.)
  cachedConfig = null;
  return true;
}

/** Type guard: is config the multi-provider format? */
function isMultiConfig(llm: LlmFileConfig | LlmMultiFileConfig): llm is LlmMultiFileConfig {
  return 'providers' in llm && Array.isArray((llm as LlmMultiFileConfig).providers);
}

/**
 * Load LLM configuration from ~/.mcpctl/config.json.
 * Returns undefined if no LLM section is configured.
 * @deprecated Use loadLlmProviders() for multi-provider support.
 */
export function loadLlmConfig(): LlmFileConfig | undefined {
  const config = loadFullConfig();
  if (!config.llm) return undefined;
  if (isMultiConfig(config.llm)) {
    // Multi-provider format — return first provider as legacy compat
    const first = config.llm.providers[0];
    if (!first) return undefined;
    const legacy: LlmFileConfig = { provider: first.type };
    if (first.model) legacy.model = first.model;
    if (first.url) legacy.url = first.url;
    if (first.binaryPath) legacy.binaryPath = first.binaryPath;
    return legacy;
  }
  if (!config.llm.provider || config.llm.provider === 'none') return undefined;
  return config.llm;
}

/**
 * Load LLM providers from ~/.mcpctl/config.json.
 * Normalizes both legacy single-provider and multi-provider formats.
 * Returns empty array if no LLM is configured.
 */
export function loadLlmProviders(): LlmProviderFileEntry[] {
  const config = loadFullConfig();
  if (!config.llm) return [];

  if (isMultiConfig(config.llm)) {
    return config.llm.providers.filter((p) => p.type !== 'none');
  }

  // Legacy single-provider format → normalize to one entry
  if (!config.llm.provider || config.llm.provider === 'none') return [];
  const entry: LlmProviderFileEntry = {
    name: config.llm.provider,
    type: config.llm.provider,
  };
  if (config.llm.model) entry.model = config.llm.model;
  if (config.llm.url) entry.url = config.llm.url;
  if (config.llm.binaryPath) entry.binaryPath = config.llm.binaryPath;
  return [entry];
}

/**
 * Load per-project LLM override from ~/.mcpctl/config.json.
 * Returns the project-specific model/provider override, or undefined.
 */
export function loadProjectLlmOverride(projectName: string): ProjectLlmOverride | undefined {
  const config = loadFullConfig();
  return config.projects?.[projectName]?.llm;
}

/**
 * Load locally-declared agents from ~/.mcpctl/config.json (v3 virtual
 * agents). Returns empty array if no agents block is configured.
 */
/**
 * v6: resolve the per-publisher suffix from config (or env override).
 * Returns the empty string when no suffix is configured (today's
 * behavior — names pass through unchanged). Sanitizes both literal
 * strings and the `"auto"` form to the same `[a-z0-9-]+` charset that
 * mcpd's name validation accepts; non-conforming characters get
 * replaced with `-` so a hostname like `Alice's-laptop.local` becomes
 * `alice-s-laptop-local`.
 *
 * Resolution order:
 *   1. Env `MCPCTL_PUBLISHER_SUFFIX` (operations override)
 *   2. config.publisher.suffix === "auto" → os.hostname()
 *   3. config.publisher.suffix === literal string
 *   4. otherwise empty
 */
export function loadPublisherSuffix(env: Record<string, string | undefined> = process.env): string {
  const fromEnv = env['MCPCTL_PUBLISHER_SUFFIX'];
  if (fromEnv !== undefined && fromEnv !== '') return sanitizeSuffix(fromEnv);
  const config = loadFullConfig();
  const suffix = config.publisher?.suffix;
  if (suffix === undefined || suffix === '') return '';
  if (suffix === 'auto') {
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const os = require('node:os') as typeof import('node:os');
    return sanitizeSuffix(os.hostname());
  }
  return sanitizeSuffix(suffix);
}

/**
 * v6: apply the suffix to a name. The result must still be a valid
 * mcpd resource name (`[a-z0-9-]{1,100}`); we leave the original name
 * untouched when no suffix is set so legacy callers see no change.
 *
 * Convention: append, don't prefix. Operators reading
 * `mcpctl get llm` typically scan by logical name first
 * (`vllm-local-qwen3-…`) and the suffix is the disambiguator at the
 * tail. The combined length is bounded — registrar refuses names
 * longer than 100 chars (server-side validation matches), erroring
 * loud rather than silently truncating.
 */
export function applyPublisherSuffix(name: string, suffix: string): string {
  if (suffix === '') return name;
  const combined = `${name}-${suffix}`;
  if (combined.length > 100) {
    throw new Error(
      `Publisher-suffixed name '${combined}' exceeds the 100-char limit; shorten the base name or the suffix.`,
    );
  }
  return combined;
}

function sanitizeSuffix(raw: string): string {
  // Lowercase, replace anything outside [a-z0-9-] with '-', collapse
  // runs of '-', strip leading/trailing '-'. End result is a stable
  // identifier per host that round-trips unchanged on subsequent
  // restarts of the same mcplocal.
  return raw
    .toLowerCase()
    .replace(/[^a-z0-9-]+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-+|-+$/g, '');
}

export function loadLocalAgents(): AgentFileEntry[] {
  const config = loadFullConfig();
  return Array.isArray(config.agents) ? config.agents : [];
}

/** Reset cached config (for testing). */
export function resetConfigCache(): void {
  cachedConfig = null;
}

export function loadHttpConfig(env: Record<string, string | undefined> = process.env): HttpConfig {
  const portStr = env['MCPLOCAL_HTTP_PORT'];
  const port = portStr !== undefined ? parseInt(portStr, 10) : DEFAULT_HTTP_PORT;

  return {
    httpPort: Number.isFinite(port) ? port : DEFAULT_HTTP_PORT,
    httpHost: env['MCPLOCAL_HTTP_HOST'] ?? DEFAULT_HTTP_HOST,
    mcpdUrl: env['MCPLOCAL_MCPD_URL'] ?? DEFAULT_MCPD_URL,
    mcpdToken: env['MCPLOCAL_MCPD_TOKEN'] ?? loadUserToken(),
    logLevel: (env['MCPLOCAL_LOG_LEVEL'] as HttpConfig['logLevel'] | undefined) ?? DEFAULT_LOG_LEVEL,
  };
}
