/**
 * Local state for `mcpctl skills sync`. Lives at
 * `~/.mcpctl/skills-state.json` (NOT under `~/.claude/skills/` — Claude
 * Code reads that tree and we don't want to pollute it with our
 * bookkeeping). Tracks installed skills + per-file SHA-256 so the next
 * sync can detect server-side changes (via top-level contentHash) and
 * client-side modifications (via per-file hash drift).
 */
import { createHash } from 'node:crypto';
import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

const STATE_SCHEMA_VERSION = 1;

export interface FileState {
  /** sha256 of the file contents at write time. */
  sha256: string;
  size: number;
}

export interface SkillState {
  id: string;
  semver: string;
  /** sha256 of the canonicalised skill body — matches mcpd's hash. */
  contentHash: string;
  scope: 'project' | 'global' | 'agent';
  installDir: string;
  files: Record<string, FileState>;
  /** sha256 of the postInstall script if any; null if none. */
  postInstallHash: string | null;
  lastSyncedAt: string;
}

export interface SkillsStateFile {
  schemaVersion: number;
  lastSync: string | null;
  lastSyncProject: string | null;
  /** keyed by skill name. */
  skills: Record<string, SkillState>;
}

const DEFAULT_PATH = join(homedir(), '.mcpctl', 'skills-state.json');

export function defaultStatePath(): string {
  return DEFAULT_PATH;
}

export function emptyState(): SkillsStateFile {
  return {
    schemaVersion: STATE_SCHEMA_VERSION,
    lastSync: null,
    lastSyncProject: null,
    skills: {},
  };
}

/**
 * Compute sha256 of a buffer or string. Matches the
 * `'sha256:'`-prefixed format mcpd produces.
 */
export function sha256Of(data: Buffer | string): string {
  const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
  return 'sha256:' + createHash('sha256').update(buf).digest('hex');
}

export async function loadState(path = DEFAULT_PATH): Promise<SkillsStateFile> {
  try {
    const raw = await readFile(path, 'utf-8');
    const parsed = JSON.parse(raw) as Partial<SkillsStateFile>;
    // Be lenient: if the schema is older or fields missing, hydrate to defaults.
    if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
      // For schemaVersion drift in v1 we treat the file as unparseable
      // and start fresh; future migrations can dispatch on the value.
      return emptyState();
    }
    return {
      schemaVersion: STATE_SCHEMA_VERSION,
      lastSync: parsed.lastSync ?? null,
      lastSyncProject: parsed.lastSyncProject ?? null,
      skills: parsed.skills ?? {},
    };
  } catch (err: unknown) {
    if (isNotFoundError(err)) {
      return emptyState();
    }
    throw err;
  }
}

/** Atomic write: temp file in the same dir, then rename. */
export async function saveState(state: SkillsStateFile, path = DEFAULT_PATH): Promise<void> {
  const dir = dirname(path);
  await mkdir(dir, { recursive: true });
  const tmp = `${path}.tmp.${String(process.pid)}`;
  await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
  await rename(tmp, path);
}

/** Detect whether on-disk file content matches what we last wrote. */
export async function hasFileBeenModified(installDir: string, relPath: string, recorded: FileState): Promise<boolean> {
  try {
    const buf = await readFile(join(installDir, relPath));
    if (buf.length !== recorded.size) return true;
    return sha256Of(buf) !== recorded.sha256;
  } catch (err: unknown) {
    if (isNotFoundError(err)) return true; // missing file ≠ pristine
    throw err;
  }
}

/** Walk a skill's installed files and report which were edited locally. */
export async function detectModifiedFiles(installDir: string, files: Record<string, FileState>): Promise<string[]> {
  const modified: string[] = [];
  for (const [rel, fs] of Object.entries(files)) {
    if (await hasFileBeenModified(installDir, rel, fs)) {
      modified.push(rel);
    }
  }
  return modified;
}

/** Check if a path exists. */
export async function pathExists(p: string): Promise<boolean> {
  try {
    await stat(p);
    return true;
  } catch (err: unknown) {
    if (isNotFoundError(err)) return false;
    throw err;
  }
}

function isNotFoundError(err: unknown): boolean {
  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT';
}
