import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';

export type FleetAgentHarness = 'claude' | 'pi';

export interface FleetAgentScaffoldOptions {
  readonly dataHome?: string;
  readonly name: string;
  readonly harness?: string;
  readonly bundle?: string;
  readonly model?: string;
}

export interface FleetAgentScaffoldResult {
  readonly agentDir: string;
  readonly profile: Readonly<Record<string, unknown>>;
  readonly idempotent: boolean;
  readonly credentialTarget: string;
  readonly credentialTargetExists: boolean;
}

export class FleetAgentScaffoldError extends Error {
  readonly code: 'invalid-request' | 'agent-exists-different';

  constructor(code: FleetAgentScaffoldError['code'], message: string) {
    super(message);
    this.name = 'FleetAgentScaffoldError';
    this.code = code;
  }
}

/** User-owned data root, deliberately distinct from the update-owned mosaic home. */
export function defaultFleetDataHome(): string {
  return process.env['MOSAIC_DATA_HOME'] ?? join(homedir(), '.mosaic');
}

/**
 * Materialize one fleet seat from authored, deterministic template content.
 * Settings composition intentionally does not happen here: launch owns the
 * three-layer settings merge and writes the generated settings.json then.
 */
export async function scaffoldFleetAgent(
  options: FleetAgentScaffoldOptions,
): Promise<FleetAgentScaffoldResult> {
  const name = requireSafeName(options.name);
  const harness = requireHarness(options.harness ?? 'claude');
  const bundle = requireBundle(options.bundle ?? 'primary');
  const model = optionalNonEmpty(options.model, '--model');
  const dataHome = resolve(options.dataHome ?? defaultFleetDataHome());
  const agentDir = join(dataHome, 'fleet', 'agents', name);
  const homeName = harness === 'claude' ? '.claude' : '.pi';
  const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
  const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName);
  const profile: Record<string, unknown> = {
    schema: 1,
    harness,
    bundle,
    overlay: 'overlay.json',
    ...(model === undefined ? {} : { model }),
    env: { MOSAIC_AGENT_NAME: name },
  };
  const entries: [string, ExpectedFile][] = [
    ['profile.json', { type: 'file', content: json(profile) }],
    ['SOUL.md', { type: 'file', content: soul(name) }],
    ['overlay.json', { type: 'file', content: '{}\n' }],
    [
      join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
      { type: 'file', content: identityBootstrap(name) },
    ],
    [join(homeName, credentialName), { type: 'symlink', target: credentialTarget }],
  ];
  if (harness === 'claude') {
    entries.push([
      join(homeName, '.claude.json'),
      { type: 'file', content: json(onboardingState()) },
    ]);
  }
  const files = new Map<string, ExpectedFile>(entries);

  const differences = await findDifferences(agentDir, files);
  if (differences.length > 0) {
    throw new FleetAgentScaffoldError(
      'agent-exists-different',
      `Agent "${name}" already exists with different user-owned file(s): ${differences.join(', ')}. Refusing to overwrite.`,
    );
  }

  const idempotent = await pathExists(agentDir);
  if (!idempotent) {
    for (const [file, expected] of files) {
      const path = join(agentDir, file);
      await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
      if (expected.type === 'file') {
        await writeFile(path, expected.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
      } else {
        // A dangling link is intentional before enrollment. It makes absent auth
        // visible at launch instead of silently selecting another account.
        await symlink(expected.target, path);
      }
    }
  }

  return {
    agentDir,
    profile,
    idempotent,
    credentialTarget,
    credentialTargetExists: await pathExists(credentialTarget),
  };
}

type ExpectedFile =
  | { readonly type: 'file'; readonly content: string }
  | { readonly type: 'symlink'; readonly target: string };

async function findDifferences(
  agentDir: string,
  expected: ReadonlyMap<string, ExpectedFile>,
): Promise<string[]> {
  let root;
  try {
    root = await lstat(agentDir);
  } catch (error: unknown) {
    if (isMissing(error)) return [];
    throw error;
  }
  if (!root.isDirectory() || root.isSymbolicLink()) return ['.'];

  const actual = await listRelativeEntries(agentDir);
  const expectedDirs = new Set<string>();
  for (const path of expected.keys()) {
    const parent = relative('.', join(path, '..'));
    if (parent !== '') expectedDirs.add(parent);
  }
  const paths = new Set([
    ...expected.keys(),
    ...actual.filter((path: string): boolean => !expectedDirs.has(path)),
  ]);
  const differences: string[] = [];
  for (const path of [...paths].sort()) {
    const required = expected.get(path);
    if (!required) {
      differences.push(path);
      continue;
    }
    try {
      const info = await lstat(join(agentDir, path));
      if (required.type === 'file') {
        if (
          !info.isFile() ||
          info.isSymbolicLink() ||
          (await readFile(join(agentDir, path), 'utf8')) !== required.content
        ) {
          differences.push(path);
        }
      } else if (
        !info.isSymbolicLink() ||
        (await readlink(join(agentDir, path))) !== required.target
      ) {
        differences.push(path);
      }
    } catch (error: unknown) {
      if (isMissing(error)) differences.push(path);
      else throw error;
    }
  }
  return differences;
}

async function listRelativeEntries(root: string, prefix = ''): Promise<string[]> {
  const result: string[] = [];
  for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
    const path = join(prefix, entry.name);
    if (entry.isDirectory() && !entry.isSymbolicLink()) {
      result.push(path, ...(await listRelativeEntries(root, path)));
    } else {
      result.push(path);
    }
  }
  return result;
}

function requireSafeName(value: string): string {
  if (
    typeof value !== 'string' ||
    value.length === 0 ||
    value === '.' ||
    value === '..' ||
    value.includes('/') ||
    value.includes('\\') ||
    value.includes('\0') ||
    isAbsolute(value)
  ) {
    throw new FleetAgentScaffoldError(
      'invalid-request',
      'Agent name must be one non-empty path component (not absolute or traversal).',
    );
  }
  return value;
}

function requireHarness(value: string): FleetAgentHarness {
  if (value === 'claude' || value === 'pi') return value;
  throw new FleetAgentScaffoldError('invalid-request', '--harness must be claude or pi.');
}

function requireBundle(value: string): string {
  if (
    typeof value !== 'string' ||
    value.length === 0 ||
    value === '.' ||
    value === '..' ||
    value.includes('/') ||
    value.includes('\\') ||
    value.includes('\0') ||
    isAbsolute(value)
  ) {
    throw new FleetAgentScaffoldError(
      'invalid-request',
      '--bundle must be one non-empty auth-bundle path component.',
    );
  }
  return value;
}

function optionalNonEmpty(value: string | undefined, option: string): string | undefined {
  if (value === undefined) return undefined;
  if (value.length === 0 || value.includes('\0')) {
    throw new FleetAgentScaffoldError('invalid-request', `${option} must be a non-empty string.`);
  }
  return value;
}

function onboardingState(): Record<string, unknown> {
  return { hasCompletedOnboarding: true, theme: 'dark' };
}

function soul(name: string): string {
  return `# SOUL\n\n## Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nRole: _Describe this seat's role._\n`;
}

/** Identity is materialized by value so restricted harness modes never need to read SOUL.md. */
function identityBootstrap(name: string): string {
  return `# Mosaic Fleet Agent Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nYour mechanical identity is ${name} (MOSAIC_AGENT_NAME). Keep this identity when working in repositories with other personas.\n`;
}

function json(value: unknown): string {
  return `${JSON.stringify(value, null, 2)}\n`;
}

async function pathExists(path: string): Promise<boolean> {
  try {
    await lstat(path);
    return true;
  } catch (error: unknown) {
    if (isMissing(error)) return false;
    throw error;
  }
}

function isMissing(error: unknown): boolean {
  return (error as NodeJS.ErrnoException).code === 'ENOENT';
}

/** Guardrail kept explicit for callers that construct paths from untrusted text. */
export function isContainedInFleetDataHome(dataHome: string, path: string): boolean {
  const rel = relative(resolve(dataHome), resolve(path));
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
