/**
 * Materialise skill-declared hooks into Claude Code's
 * `~/.claude/settings.json`.
 *
 * Each entry we write carries two markers:
 *   `_mcpctl_managed: true` — same flag the SessionStart-hook
 *      installer uses; identifies an entry mcpctl owns.
 *   `_mcpctl_source: "<skill-name>"` — which skill installed it.
 *
 * The combination lets us cleanly add/update/remove skill hooks without
 * clobbering hooks the user added by hand and without one skill trampling
 * another. Removing skill X re-reads the file, drops every entry tagged
 * `_mcpctl_source: "X"`, and rewrites atomically.
 *
 * Claude Code ignores the extra fields (it only looks at `type` and
 * `command`).
 *
 * The file is written atomically (temp + rename) and tolerant of an
 * existing file that has comments, no `hooks` block, or unexpected
 * shape — same robustness profile as sessionhook.ts.
 */
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

import { MARKER_KEY } from './sessionhook.js';

export const SOURCE_KEY = '_mcpctl_source';

/** A single hook entry: must be `type: 'command'` for v1. Extra fields preserved. */
export interface ManagedHookEntry {
  type: 'command';
  command: string;
  timeout?: number;
  /** Free-form: skills can attach extra fields and they'll round-trip. */
  [k: string]: unknown;
}

/** Recognised hook events. Validated server-side; if a new event lands later, we still write whatever the skill declares. */
export type HookEvent =
  | 'PreToolUse'
  | 'PostToolUse'
  | 'SessionStart'
  | 'Stop'
  | 'SubagentStop'
  | 'Notification';

export type HooksByEvent = Partial<Record<HookEvent, ManagedHookEntry[]>>;

interface HookGroup {
  hooks: ManagedHookEntry[];
  [k: string]: unknown;
}

interface Settings {
  hooks?: Partial<Record<string, HookGroup[]>>;
  [k: string]: unknown;
}

function defaultSettingsPath(): string {
  return join(homedir(), '.claude', 'settings.json');
}

async function readSettings(path: string): Promise<Settings> {
  try {
    const raw = await readFile(path, 'utf-8');
    if (raw.trim().length === 0) return {};
    const stripped = raw.replace(/^\s*\/\/.*$/gm, '');
    return JSON.parse(stripped) as Settings;
  } catch (err: unknown) {
    if (isNotFoundError(err)) return {};
    throw err;
  }
}

async function writeSettings(path: string, settings: Settings): Promise<void> {
  await mkdir(dirname(path), { recursive: true });
  const tmp = `${path}.tmp.${String(process.pid)}`;
  await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
  await rename(tmp, path);
}

function isManagedBy(entry: unknown, source: string): boolean {
  if (entry === null || typeof entry !== 'object') return false;
  const e = entry as Record<string, unknown>;
  return e[MARKER_KEY] === true && e[SOURCE_KEY] === source;
}

/**
 * Replace this skill's hook entries with the provided set. If `hooks`
 * omits an event the skill previously installed, those entries are
 * dropped. Other skills' entries and user-added entries are preserved.
 *
 * Returns the count of changes (added or removed entries) so callers
 * can short-circuit no-op writes.
 */
export async function applyManagedHooks(
  source: string,
  hooks: HooksByEvent,
  settingsPath: string = defaultSettingsPath(),
): Promise<{ updated: boolean; settingsPath: string }> {
  const settings = await readSettings(settingsPath);
  if (!settings.hooks) settings.hooks = {};

  let changed = false;

  // For each known/declared event, drop our previous entries and add the new ones.
  const declaredEvents = new Set<string>(Object.keys(hooks));
  // Also walk events that already have entries from this source (so skills can shrink scope).
  for (const [eventName, groups] of Object.entries(settings.hooks)) {
    if (!Array.isArray(groups)) continue;
    if (groups.some((g) => Array.isArray(g.hooks) && g.hooks.some((e) => isManagedBy(e, source)))) {
      declaredEvents.add(eventName);
    }
  }

  for (const eventName of declaredEvents) {
    const desired = hooks[eventName as HookEvent] ?? [];
    const groups = (settings.hooks[eventName] as HookGroup[] | undefined) ?? [];

    // Strip our entries from each group, then drop empty groups.
    const stripped: HookGroup[] = [];
    for (const group of groups) {
      if (!Array.isArray(group?.hooks)) {
        stripped.push(group);
        continue;
      }
      const before = group.hooks.length;
      const filtered = group.hooks.filter((e) => !isManagedBy(e, source));
      if (filtered.length !== before) changed = true;
      if (filtered.length > 0) {
        stripped.push({ ...group, hooks: filtered });
      }
    }

    // Insert the new set as a single group tagged with our source.
    if (desired.length > 0) {
      const tagged = desired.map((entry) => ({
        ...entry,
        type: 'command' as const,
        [MARKER_KEY]: true,
        [SOURCE_KEY]: source,
      }));
      stripped.push({ hooks: tagged, [SOURCE_KEY]: source });
      changed = true;
    }

    if (stripped.length === 0) {
      // No groups left for this event — drop the event entirely so the
      // settings.json doesn't accumulate empty arrays.
      delete settings.hooks[eventName];
    } else {
      settings.hooks[eventName] = stripped;
    }
  }

  if (!changed) {
    return { updated: false, settingsPath };
  }

  await writeSettings(settingsPath, settings);
  return { updated: true, settingsPath };
}

/**
 * Drop all hook entries owned by `source`. Used by the sync's orphan-
 * removal path so a skill that's no longer in the server set
 * un-registers its hooks too. Returns whether anything was changed.
 */
export async function removeManagedHooks(
  source: string,
  settingsPath: string = defaultSettingsPath(),
): Promise<{ removed: boolean; settingsPath: string }> {
  const result = await applyManagedHooks(source, {}, settingsPath);
  return { removed: result.updated, settingsPath: result.settingsPath };
}

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