import { Command } from 'commander';
import { join } from 'node:path';
import { homedir } from 'node:os';

import type { ApiClient } from '../api-client.js';
import { findProjectMarker } from '../utils/project-marker.js';
import {
  loadState,
  saveState,
  detectModifiedFiles,
  type SkillState,
  defaultStatePath,
} from '../utils/skills-state.js';
import {
  installSkillAtomic,
  removeSkillAtomic,
  type SkillBody,
} from '../utils/skills-disk.js';
import {
  runPostInstall,
  emitPostInstallAudit,
  hashScript,
} from '../utils/postinstall.js';
import {
  applyManagedHooks,
  removeManagedHooks,
  type HooksByEvent,
} from '../utils/hooks-materialiser.js';
import {
  attachSkillMcpServers,
  parseMcpServerDeps,
} from '../utils/mcpservers-materialiser.js';
import { ApiError } from '../api-client.js';

/**
 * `mcpctl skills sync` — materialise server-side skills onto disk under
 * `~/.claude/skills/<name>/`. Per-skill atomic install; hash-pinned diff
 * (server-computed contentHash); user-modification preservation.
 *
 * Failure semantics: in `--quiet` mode (used by the SessionStart hook),
 * exit code 0 on network/timeout (fail-open so a hung mcpd never blocks
 * Claude startup). Auth errors exit 1; disk errors exit 2.
 */

interface VisibleSkill {
  id: string;
  name: string;
  description: string;
  semver: string;
  contentHash: string;
  metadata: unknown;
  scope: 'project' | 'global' | 'agent';
}

interface FullSkill {
  id: string;
  name: string;
  description: string;
  content: string;
  files: Record<string, string>;
  metadata: Record<string, unknown>;
  semver: string;
  projectId: string | null;
  agentId: string | null;
}

/**
 * Shape of `metadata` we care about at sync time. Validated server-side
 * by SkillMetadataSchema (PR-3); we re-narrow here for the fields the
 * sync acts on, keeping the rest opaque so future additions don't
 * require a CLI change.
 */
interface SyncedSkillMetadata {
  postInstall?: unknown;
  postInstallTimeoutSec?: unknown;
  hooks?: unknown;
  mcpServers?: unknown;
}

export interface SyncOpts {
  /** Project name override; otherwise marker walk-up + fall back to globals-only. */
  project?: string;
  dryRun?: boolean;
  force?: boolean;
  quiet?: boolean;
  skipPostInstall?: boolean;
  keepOrphans?: boolean;
  /** For tests: override cwd start for the marker walk-up. */
  cwd?: string;
  /** For tests: override skills install root (default: ~/.claude/skills). */
  installRoot?: string;
  /** For tests: override state file path. */
  statePath?: string;
}

export interface SyncResult {
  installed: string[];
  updated: string[];
  skipped: string[];
  removed: string[];
  preserved: string[]; // skills with local edits we left alone
  postInstallsRan: string[]; // skills whose postInstall executed in this sync
  postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun
  hooksApplied: string[]; // skills whose hooks were registered/updated in ~/.claude/settings.json
  mcpServersAttached: string[]; // "<skill>:<server>" tuples that landed in this sync
  errors: Array<{ skill: string; error: string }>;
  exitCode: 0 | 1 | 2;
}

export interface SyncDeps {
  client: ApiClient;
  log: (...args: unknown[]) => void;
  /** stderr writer. Defaults to console.error. */
  warn: (...args: unknown[]) => void;
}

/**
 * Library entry — call from `mcpctl config claude --project X` and from
 * the `skills sync` Commander action.
 */
export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<SyncResult> {
  const { client, log, warn } = deps;
  const result: SyncResult = {
    installed: [],
    updated: [],
    skipped: [],
    removed: [],
    preserved: [],
    postInstallsRan: [],
    postInstallsSkipped: [],
    hooksApplied: [],
    mcpServersAttached: [],
    errors: [],
    exitCode: 0,
  };

  // 1. Resolve project scope.
  let projectName = opts.project;
  if (!projectName) {
    const marker = await findProjectMarker(opts.cwd ?? process.cwd());
    if (marker) projectName = marker.project;
  }

  // 2. Fetch the visible skill list.
  let visible: VisibleSkill[];
  try {
    if (projectName) {
      visible = await client.get<VisibleSkill[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`);
    } else {
      // No project context — sync only globals.
      const all = await client.get<VisibleSkill[]>('/api/v1/skills?scope=global');
      visible = all;
    }
  } catch (err: unknown) {
    if (err instanceof ApiError && err.status === 401) {
      warn('mcpctl: auth failed — run `mcpctl login`');
      result.exitCode = 1;
      return result;
    }
    if (opts.quiet) {
      // Fail-open in quiet mode (SessionStart hook context). The next sync
      // will catch up; we never want to block Claude startup on a hung mcpd.
      warn(`mcpctl: skills sync skipped — ${err instanceof Error ? err.message : String(err)}`);
      result.exitCode = 0;
      return result;
    }
    throw err;
  }

  // Filter agent-scoped skills for now — sync targets globals + project skills,
  // but agent-scoped skills aren't surfaced to a user's Claude Code session
  // (they're administrative). PR-3+ may revisit if agent-identity-on-disk
  // becomes a concept.
  visible = visible.filter((s) => s.scope !== 'agent');

  // 3. Load state.
  const statePath = opts.statePath ?? defaultStatePath();
  const state = await loadState(statePath);
  const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills');

  // 4. Diff.
  const visibleByName = new Map(visible.map((s) => [s.name, s]));
  const stateNames = Object.keys(state.skills);

  // Determine install/update/skip per server skill.
  const toFetch: VisibleSkill[] = [];
  for (const v of visible) {
    const prior = state.skills[v.name];
    if (!prior) {
      toFetch.push(v);
      continue;
    }
    if (prior.contentHash === v.contentHash) {
      result.skipped.push(v.name);
      continue;
    }
    // Hash differs — content changed server-side. Need to fetch full body.
    toFetch.push(v);
  }

  // 5. Apply install/update with concurrency limit (5 in-flight fetches).
  const concurrency = 5;
  for (let i = 0; i < toFetch.length; i += concurrency) {
    const batch = toFetch.slice(i, i + concurrency);
    await Promise.all(batch.map((v) => applyOne(v)));
  }

  // 6. Orphan removal: skills in state but not in server's visible set.
  if (!opts.keepOrphans) {
    for (const name of stateNames) {
      if (visibleByName.has(name)) continue;
      const prior = state.skills[name];
      if (!prior) continue;
      try {
        // Preserve user-modified skills — warn + skip.
        const modified = await detectModifiedFiles(prior.installDir, prior.files);
        if (modified.length > 0 && !opts.force) {
          warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`);
          result.preserved.push(name);
          continue;
        }
        if (opts.dryRun) {
          result.removed.push(name);
          continue;
        }
        await removeSkillAtomic(prior.installDir);
        // Drop any hook entries this skill registered.
        try { await removeManagedHooks(name); } catch { /* best-effort */ }
        delete state.skills[name];
        result.removed.push(name);
      } catch (err: unknown) {
        result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) });
      }
    }
  }

  // 7. Persist state.
  state.lastSync = new Date().toISOString();
  if (projectName !== undefined) state.lastSyncProject = projectName;
  if (!opts.dryRun) {
    try {
      await saveState(state, statePath);
    } catch (err: unknown) {
      warn(`mcpctl: failed to persist state — ${err instanceof Error ? err.message : String(err)}`);
      result.exitCode = 2;
    }
  }

  // 8. Summary.
  const anythingHappened =
    result.errors.length > 0 ||
    result.installed.length > 0 ||
    result.updated.length > 0 ||
    result.removed.length > 0 ||
    result.postInstallsRan.length > 0 ||
    result.hooksApplied.length > 0 ||
    result.mcpServersAttached.length > 0;
  if (!opts.quiet || anythingHappened) {
    const parts: string[] = [];
    if (result.installed.length) parts.push(`${String(result.installed.length)} installed`);
    if (result.updated.length) parts.push(`${String(result.updated.length)} updated`);
    if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`);
    if (result.removed.length) parts.push(`${String(result.removed.length)} removed`);
    if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`);
    if (result.postInstallsRan.length) parts.push(`${String(result.postInstallsRan.length)} postInstall ran`);
    if (result.hooksApplied.length) parts.push(`${String(result.hooksApplied.length)} hooks applied`);
    if (result.mcpServersAttached.length) parts.push(`${String(result.mcpServersAttached.length)} mcpServers attached`);
    if (result.errors.length) parts.push(`${String(result.errors.length)} errors`);
    if (parts.length === 0) parts.push('no changes');
    if (!opts.quiet) {
      log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
    } else if (anythingHappened) {
      // Quiet mode: only emit a single line if something actually happened.
      warn(`mcpctl: ${parts.join(', ')}`);
    }
  }

  return result;

  async function applyOne(v: VisibleSkill): Promise<void> {
    try {
      // If on-disk files were locally modified, preserve unless --force.
      const prior = state.skills[v.name];
      const targetDir = prior?.installDir ?? join(installRoot, v.name);
      if (prior && !opts.force) {
        const modified = await detectModifiedFiles(prior.installDir, prior.files);
        if (modified.length > 0) {
          warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`);
          result.preserved.push(v.name);
          return;
        }
      }
      if (opts.dryRun) {
        if (prior) result.updated.push(v.name);
        else result.installed.push(v.name);
        return;
      }

      const full = await client.get<FullSkill>(`/api/v1/skills/${encodeURIComponent(v.id)}`);
      const body: SkillBody = {
        content: full.content,
        ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}),
      };
      const fileStates = await installSkillAtomic(targetDir, body);

      // ── hooks: register metadata.hooks in ~/.claude/settings.json ──
      // Tagged with _mcpctl_source: <skill-name> so each skill's hooks
      // can be cleanly added/updated/removed without trampling other
      // skills or user-added hooks. No-op when the field is absent or
      // empty.
      const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
      if (meta.hooks && typeof meta.hooks === 'object') {
        try {
          const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
          if (hookRes.updated) result.hooksApplied.push(v.name);
        } catch (err: unknown) {
          warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
        }
      } else if (prior !== undefined) {
        // Skill no longer declares hooks but used to — clean up.
        try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
      }

      // ── mcpServers: auto-attach declared deps to the active project ──
      // Only meaningful when a project context is active; global skills
      // can't attach to "no project". v1 doesn't auto-create missing
      // servers (warn + skip). Idempotent — re-syncing a skill whose
      // deps are already attached is a no-op.
      const mcpServerDeps = parseMcpServerDeps(meta.mcpServers);
      if (mcpServerDeps.length > 0 && projectName) {
        try {
          const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn);
          for (const srv of att.attached) {
            result.mcpServersAttached.push(`${v.name}:${srv}`);
          }
          for (const e of att.errors) {
            result.errors.push({
              skill: v.name,
              error: `mcpServers attach '${e.server}': ${e.error}`,
            });
          }
        } catch (err: unknown) {
          warn(`mcpctl: failed to attach mcpServers for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
        }
      } else if (mcpServerDeps.length > 0) {
        warn(`mcpctl: skill '${v.name}' declares mcpServers but sync is running global-only; skipping attach`);
      }

      // ── postInstall: run metadata.postInstall when present ──
      // Hash-pinned: only execute when the script's sha256 differs from
      // what state recorded. Failures DO NOT update the recorded hash so
      // the next sync retries. Other skills continue regardless.
      let postInstallHash: string | null = prior?.postInstallHash ?? null;
      if (
        !opts.skipPostInstall &&
        typeof meta.postInstall === 'string' &&
        meta.postInstall.length > 0
      ) {
        const scriptRel = meta.postInstall;
        const scriptContent = (full.files ?? {})[scriptRel];
        if (typeof scriptContent !== 'string') {
          warn(`mcpctl: skill '${v.name}' postInstall references '${scriptRel}' which is not in files{}; skipping`);
        } else {
          const newHash = hashScript(scriptContent);
          const hashChanged = newHash !== prior?.postInstallHash;
          if (!hashChanged) {
            result.postInstallsSkipped.push(v.name);
            postInstallHash = newHash;
          } else {
            try {
              const timeoutSec = typeof meta.postInstallTimeoutSec === 'number' ? meta.postInstallTimeoutSec : undefined;
              const piInput = {
                installDir: targetDir,
                scriptPath: scriptRel,
                skillName: v.name,
                semver: v.semver,
                projectName: projectName ?? undefined,
                timeoutSec,
                logsDir: join(homedir(), '.mcpctl', 'skills', v.name),
              };
              const installResult = await runPostInstall(piInput);
              // Best-effort audit. Don't await; mcpd slowness shouldn't slow sync.
              void emitPostInstallAudit(client, piInput, installResult, (m) => warn(m));

              if (installResult.timedOut) {
                result.errors.push({
                  skill: v.name,
                  error: `postInstall timed out after ${String(installResult.durationMs)}ms; rerun next sync`,
                });
                // hash NOT updated → retry on next sync
              } else if (installResult.exitCode !== 0) {
                const tail = installResult.stderrTail.trim() || installResult.stdoutTail.trim() || `exit ${String(installResult.exitCode)}`;
                result.errors.push({
                  skill: v.name,
                  error: `postInstall failed (exit ${String(installResult.exitCode)}): ${tail.slice(-200)}`,
                });
                // hash NOT updated → retry on next sync
              } else {
                postInstallHash = installResult.scriptHash;
                result.postInstallsRan.push(v.name);
              }
            } catch (err: unknown) {
              result.errors.push({
                skill: v.name,
                error: `postInstall error: ${err instanceof Error ? err.message : String(err)}`,
              });
            }
          }
        }
      }

      const newState: SkillState = {
        id: v.id,
        semver: v.semver,
        contentHash: v.contentHash,
        scope: v.scope,
        installDir: targetDir,
        files: fileStates,
        postInstallHash,
        lastSyncedAt: new Date().toISOString(),
      };
      state.skills[v.name] = newState;
      if (prior) result.updated.push(v.name);
      else result.installed.push(v.name);
    } catch (err: unknown) {
      result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) });
    }
  }
}

// ── Commander wrapper ──

export interface SkillsCommandDeps {
  client: ApiClient;
  log: (...args: unknown[]) => void;
}

export function createSkillsCommand(deps: SkillsCommandDeps): Command {
  const { client, log } = deps;
  const warn = (...args: unknown[]): void => {
    console.error(...(args as Parameters<typeof console.error>));
  };

  const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd');

  cmd.command('sync')
    .description('Sync skills from mcpd onto disk under ~/.claude/skills/')
    .option('-p, --project <name>', 'Project to sync (overrides .mcpctl-project marker)')
    .option('--dry-run', 'Print what would change without writing anything')
    .option('--force', 'Overwrite locally-modified skills')
    .option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)')
    .option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)')
    .option('--keep-orphans', 'Do not remove skills that are no longer in the server set')
    .action(async (opts: {
      project?: string;
      dryRun?: boolean;
      force?: boolean;
      quiet?: boolean;
      skipPostinstall?: boolean;
      keepOrphans?: boolean;
    }) => {
      const result = await runSkillsSync(
        {
          ...(opts.project !== undefined ? { project: opts.project } : {}),
          ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}),
          ...(opts.force !== undefined ? { force: opts.force } : {}),
          ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
          ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
          ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
        },
        { client, log, warn },
      );
      if (result.exitCode !== 0) {
        process.exitCode = result.exitCode;
      }
    });

  return cmd;
}
