import AdmZip from 'adm-zip';
import {
  existsSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  renameSync,
  rmSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from 'fs';
import { basename, dirname, extname, join, posix, relative, resolve, isAbsolute } from 'path';
import { ensureUserFolder, resolveUserSubdir, userRoot } from './paths.js';

const MAX_ZIP_BYTES = 12 * 1024 * 1024;
const MAX_UNCOMPRESSED_BYTES = 10 * 1024 * 1024;
const MAX_SINGLE_FILE_BYTES = 5 * 1024 * 1024;
const MAX_FILE_COUNT = 32;
const PET_ID_REGEX = /^[a-z0-9_-]{1,64}$/;
const WORKER_ID_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
const MAX_WORKER_PETS_ENTRIES = 64;
const ALLOWED_EXTENSIONS = new Set(['.json', '.png', '.webp']);
const ALLOWED_SIZES = new Set([32, 48, 64, 80]);

export interface PetSettings {
  enabled: boolean;
  activePetId: string | null;
  size: 32 | 48 | 64 | 80;
  position: 'bottom-right';
  sound: boolean;
  reducedMotion: boolean;
  toolSparkEnabled: boolean;
  workerPets: Record<string, string>;
}

export interface PetSummary {
  id: string;
  name: string;
  description: string | null;
  spriteFile: string | null;
  previewFile: string | null;
  frameWidth: number | null;
  frameHeight: number | null;
  gridCols: number | null;
  gridRows: number | null;
  updatedAt: string;
}

export interface PetDetail extends PetSummary {
  manifest: Record<string, unknown>;
}

export const DEFAULT_PET_SETTINGS: PetSettings = {
  enabled: true,
  activePetId: null,
  size: 64,
  position: 'bottom-right',
  sound: false,
  reducedMotion: false,
  toolSparkEnabled: true,
  workerPets: {},
};

export class PetConflictError extends Error {
  constructor(public readonly petId: string) {
    super(`Pet already exists: ${petId}`);
    this.name = 'PetConflictError';
  }
}

export class PetValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'PetValidationError';
  }
}

const utcTimestamp = (d: Date): string => {
  const pad = (n: number, len = 2) => String(n).padStart(len, '0');
  return (
    `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
    `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
  );
};

const writeAtomic = (path: string, content: string): void => {
  const dir = dirname(path);
  mkdirSync(dir, { recursive: true, mode: 0o700 });
  const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  let renamed = false;
  try {
    writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
    renameSync(tmp, path);
    renamed = true;
  } finally {
    if (!renamed) {
      try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
    }
  }
};

export const slugifyPetId = (input: string | null | undefined): string => {
  const slug = (input ?? '')
    .trim()
    .toLowerCase()
    .replace(/\.[^.]+$/, '')
    .replace(/[^a-z0-9_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 64);
  return PET_ID_REGEX.test(slug) ? slug : `pet-${Date.now().toString(36)}`;
};

const settingsPath = (rootDir: string, ownerId: string): string =>
  join(userRoot(rootDir, ownerId), 'pet-settings.json');

const trashPath = (rootDir: string, ownerId: string, name: string): string => {
  const trashDir = join(userRoot(rootDir, ownerId), 'trash');
  mkdirSync(trashDir, { recursive: true, mode: 0o700 });
  const ts = utcTimestamp(new Date());
  const suffix = Math.random().toString(16).slice(2, 6);
  return join(trashDir, `${ts}-${suffix}-${name}`);
};

const isZipSymlink = (entry: AdmZip.IZipEntry): boolean => {
  const mode = (entry.header.attr >> 16) & 0o170000;
  return mode === 0o120000;
};

const normalizeZipPath = (entryName: string): string => {
  const normalized = entryName.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
  if (!normalized || normalized.includes('\0')) {
    throw new PetValidationError('zip contains an empty or invalid path');
  }
  const parts = normalized.split('/');
  if (parts.some(part => !part || part === '.' || part === '..' || part.startsWith('.'))) {
    throw new PetValidationError(`zip contains an unsafe path: ${entryName}`);
  }
  if (posix.normalize(normalized) !== normalized) {
    throw new PetValidationError(`zip contains a non-normal path: ${entryName}`);
  }
  return normalized;
};

const stripBasePrefix = (entryPath: string, basePrefix: string): string | null => {
  if (!basePrefix) return entryPath;
  if (!entryPath.startsWith(basePrefix)) return null;
  const rel = entryPath.slice(basePrefix.length);
  return rel || null;
};

const readManifest = (path: string): Record<string, unknown> => {
  try {
    const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
      throw new Error('manifest must be an object');
    }
    return parsed as Record<string, unknown>;
  } catch (err) {
    throw new PetValidationError(`invalid pet.json: ${(err as Error).message}`);
  }
};

const pickString = (value: unknown): string | null =>
  typeof value === 'string' && value.trim() ? value.trim() : null;

const pickPositiveInt = (
  manifest: Record<string, unknown>,
  keys: string[],
  min: number,
  max: number,
): number | null => {
  const tryRead = (source: Record<string, unknown>): number | null => {
    for (const key of keys) {
      const value = source[key];
      if (typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max) {
        return value;
      }
    }
    return null;
  };
  const direct = tryRead(manifest);
  if (direct !== null) return direct;
  for (const containerKey of ['spritesheet', 'spriteSheet', 'sprite']) {
    const nested = manifest[containerKey];
    if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
      const fromNested = tryRead(nested as Record<string, unknown>);
      if (fromNested !== null) return fromNested;
    }
  }
  return null;
};

const pickAsset = (manifest: Record<string, unknown>, keys: string[], files: Set<string>): string | null => {
  for (const key of keys) {
    const value = pickString(manifest[key]);
    if (value && files.has(value)) return value;
  }
  for (const key of keys) {
    const nested = manifest[key];
    if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
      const file = pickString((nested as Record<string, unknown>)['file']);
      if (file && files.has(file)) return file;
    }
  }
  return null;
};

const summarizePetDir = (rootDir: string, ownerId: string, petId: string): PetDetail | null => {
  let dir: string;
  try {
    dir = resolveUserSubdir(rootDir, ownerId, 'pets', petId);
  } catch {
    return null;
  }
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return null;

  const manifestPath = join(dir, 'pet.json');
  if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) return null;

  const manifest = readManifest(manifestPath);
  const entries = readdirSync(dir, { withFileTypes: true });
  const files = new Set(entries.filter(e => e.isFile()).map(e => e.name));
  const spriteFile =
    pickAsset(manifest, ['spritesheetPath', 'spritesheet', 'spriteSheet', 'sprite', 'image'], files) ??
    (files.has('spritesheet.webp') ? 'spritesheet.webp' : null) ??
    (files.has('spritesheet.png') ? 'spritesheet.png' : null);
  const previewFile =
    pickAsset(manifest, ['preview', 'thumbnail', 'icon'], files) ??
    (files.has('preview.png') ? 'preview.png' : null) ??
    (files.has('preview.webp') ? 'preview.webp' : null);
  const frameWidth = pickPositiveInt(manifest, ['frameWidth', 'frame_width', 'frameW'], 1, 4096);
  const frameHeight = pickPositiveInt(manifest, ['frameHeight', 'frame_height', 'frameH'], 1, 4096);
  const gridCols = pickPositiveInt(manifest, ['gridCols', 'cols', 'columns'], 1, 64);
  const gridRows = pickPositiveInt(manifest, ['gridRows', 'rows'], 1, 64);
  const stat = statSync(manifestPath);

  return {
    id: petId,
    name: pickString(manifest['displayName']) ?? pickString(manifest['name']) ?? petId,
    description: pickString(manifest['description']),
    spriteFile,
    previewFile,
    frameWidth: frameWidth !== null && frameHeight !== null ? frameWidth : null,
    frameHeight: frameWidth !== null && frameHeight !== null ? frameHeight : null,
    gridCols,
    gridRows,
    updatedAt: stat.mtime.toISOString(),
    manifest,
  };
};

const validateSettings = (input: unknown, previous: PetSettings = DEFAULT_PET_SETTINGS): PetSettings => {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw new PetValidationError('settings must be an object');
  }
  const raw = input as Record<string, unknown>;
  const next: PetSettings = { ...previous };

  if ('enabled' in raw) {
    if (typeof raw.enabled !== 'boolean') throw new PetValidationError('enabled must be boolean');
    next.enabled = raw.enabled;
  }
  if ('toolSparkEnabled' in raw) {
    if (typeof raw.toolSparkEnabled !== 'boolean') throw new PetValidationError('toolSparkEnabled must be boolean');
    next.toolSparkEnabled = raw.toolSparkEnabled;
  }
  if ('sound' in raw) {
    if (typeof raw.sound !== 'boolean') throw new PetValidationError('sound must be boolean');
    next.sound = raw.sound;
  }
  if ('reducedMotion' in raw) {
    if (typeof raw.reducedMotion !== 'boolean') throw new PetValidationError('reducedMotion must be boolean');
    next.reducedMotion = raw.reducedMotion;
  }
  if ('activePetId' in raw) {
    if (raw.activePetId !== null && typeof raw.activePetId !== 'string') {
      throw new PetValidationError('activePetId must be string or null');
    }
    if (typeof raw.activePetId === 'string' && raw.activePetId && !PET_ID_REGEX.test(raw.activePetId)) {
      throw new PetValidationError('activePetId is invalid');
    }
    next.activePetId = raw.activePetId === '' ? null : raw.activePetId;
  }
  if ('size' in raw) {
    if (typeof raw.size !== 'number' || !ALLOWED_SIZES.has(raw.size)) {
      throw new PetValidationError('size must be one of 32, 48, 64, 80');
    }
    next.size = raw.size as PetSettings['size'];
  }
  if ('position' in raw) {
    if (raw.position !== 'bottom-right') throw new PetValidationError('position must be bottom-right');
    next.position = 'bottom-right';
  }
  if ('workerPets' in raw) {
    const wp = raw.workerPets;
    if (!wp || typeof wp !== 'object' || Array.isArray(wp)) {
      throw new PetValidationError('workerPets must be an object');
    }
    const entries = Object.entries(wp as Record<string, unknown>);
    if (entries.length > MAX_WORKER_PETS_ENTRIES) {
      throw new PetValidationError(`workerPets has too many entries (max ${MAX_WORKER_PETS_ENTRIES})`);
    }
    const accepted: Record<string, string> = {};
    for (const [key, value] of entries) {
      if (!WORKER_ID_REGEX.test(key)) {
        throw new PetValidationError(`workerPets key is invalid: ${key}`);
      }
      if (value === null || value === '') {
        // explicit removal via empty value
        continue;
      }
      if (typeof value !== 'string' || !PET_ID_REGEX.test(value)) {
        throw new PetValidationError(`workerPets[${key}] must be a valid pet id`);
      }
      accepted[key] = value;
    }
    next.workerPets = accepted;
  }

  return next;
};

export const readPetSettings = (rootDir: string, ownerId: string): PetSettings => {
  ensureUserFolder(rootDir, ownerId);
  const path = settingsPath(rootDir, ownerId);
  if (!existsSync(path)) return { ...DEFAULT_PET_SETTINGS };
  try {
    const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
    return validateSettings(parsed, DEFAULT_PET_SETTINGS);
  } catch {
    const trashed = trashPath(rootDir, ownerId, 'pet-settings.json');
    try { renameSync(path, trashed); } catch { /* ignore failed quarantine */ }
    return { ...DEFAULT_PET_SETTINGS };
  }
};

export const writePetSettings = (
  rootDir: string,
  ownerId: string,
  patch: unknown,
): PetSettings => {
  ensureUserFolder(rootDir, ownerId);
  const previous = readPetSettings(rootDir, ownerId);
  const next = validateSettings(patch, previous);
  writeAtomic(settingsPath(rootDir, ownerId), JSON.stringify(next, null, 2));
  return next;
};

export const listPets = (rootDir: string, ownerId: string): PetSummary[] => {
  ensureUserFolder(rootDir, ownerId);
  const petsDir = join(userRoot(rootDir, ownerId), 'pets');
  const entries = readdirSync(petsDir, { withFileTypes: true });
  return entries
    .filter(e => e.isDirectory() && PET_ID_REGEX.test(e.name))
    .map(e => summarizePetDir(rootDir, ownerId, e.name))
    .filter((pet): pet is PetDetail => pet !== null)
    .map(({ manifest: _manifest, ...summary }) => summary)
    .sort((a, b) => a.name.localeCompare(b.name));
};

export const getPet = (rootDir: string, ownerId: string, petId: string): PetDetail | null => {
  if (!PET_ID_REGEX.test(petId)) return null;
  ensureUserFolder(rootDir, ownerId);
  return summarizePetDir(rootDir, ownerId, petId);
};

export const importPetZip = (
  rootDir: string,
  ownerId: string,
  zipBytes: Buffer,
  options: { preferredId?: string | null; overwrite?: boolean } = {},
): PetDetail => {
  if (zipBytes.length === 0) throw new PetValidationError('zip body is empty');
  if (zipBytes.length > MAX_ZIP_BYTES) throw new PetValidationError('zip body is too large');

  ensureUserFolder(rootDir, ownerId);
  const zip = new AdmZip(zipBytes);
  const entries = zip.getEntries();
  if (entries.length === 0) throw new PetValidationError('zip is empty');

  const normalizedEntries = entries.map(entry => ({
    entry,
    path: normalizeZipPath(entry.entryName),
  }));
  const manifestEntry = normalizedEntries.find(({ entry, path }) => !entry.isDirectory && basename(path) === 'pet.json');
  if (!manifestEntry) throw new PetValidationError('pet.json is required');

  const basePrefix = dirname(manifestEntry.path) === '.' ? '' : `${dirname(manifestEntry.path)}/`;
  const parsedManifest = JSON.parse(manifestEntry.entry.getData().toString('utf-8')) as unknown;
  if (!parsedManifest || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) {
    throw new PetValidationError('invalid pet.json: manifest must be an object');
  }
  const manifest = parsedManifest as Record<string, unknown>;
  const preferred = options.preferredId ?? pickString(manifest['id']) ?? pickString(manifest['name']) ?? basename(basePrefix || 'pet');
  const petId = slugifyPetId(preferred);
  const destDir = resolveUserSubdir(rootDir, ownerId, 'pets', petId);
  if (existsSync(destDir) && !options.overwrite) {
    throw new PetConflictError(petId);
  }

  const petsDir = join(userRoot(rootDir, ownerId), 'pets');
  mkdirSync(petsDir, { recursive: true, mode: 0o700 });
  const tmpDir = join(petsDir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  mkdirSync(tmpDir, { recursive: true, mode: 0o700 });

  let fileCount = 0;
  let totalBytes = 0;
  let wroteManifest = false;
  try {
    for (const { entry, path } of normalizedEntries) {
      if (entry.isDirectory) continue;
      if (isZipSymlink(entry)) throw new PetValidationError('zip symlinks are not allowed');

      const relPath = stripBasePrefix(path, basePrefix);
      if (!relPath) continue;
      if (relPath.includes('/')) throw new PetValidationError(`nested files are not supported: ${relPath}`);
      const ext = extname(relPath).toLowerCase();
      if (!ALLOWED_EXTENSIONS.has(ext)) throw new PetValidationError(`unsupported file type: ${relPath}`);

      const data = entry.getData();
      fileCount += 1;
      totalBytes += data.length;
      if (fileCount > MAX_FILE_COUNT) throw new PetValidationError('zip contains too many files');
      if (data.length > MAX_SINGLE_FILE_BYTES) throw new PetValidationError(`file is too large: ${relPath}`);
      if (totalBytes > MAX_UNCOMPRESSED_BYTES) throw new PetValidationError('zip uncompressed size is too large');

      const outPath = resolve(tmpDir, relPath);
      if (relative(tmpDir, outPath).startsWith('..') || isAbsolute(relative(tmpDir, outPath))) {
        throw new PetValidationError(`unsafe output path: ${relPath}`);
      }
      writeFileSync(outPath, data, { mode: 0o600 });
      if (relPath === 'pet.json') wroteManifest = true;
    }

    if (!wroteManifest) throw new PetValidationError('pet.json is required at package root');
    readManifest(join(tmpDir, 'pet.json'));

    if (existsSync(destDir)) {
      renameSync(destDir, trashPath(rootDir, ownerId, `${petId}-pet`));
    }
    renameSync(tmpDir, destDir);
  } catch (err) {
    rmSync(tmpDir, { recursive: true, force: true });
    throw err;
  }

  const detail = getPet(rootDir, ownerId, petId);
  if (!detail) throw new PetValidationError('imported pet could not be read');
  return detail;
};

export const deletePet = (rootDir: string, ownerId: string, petId: string): boolean => {
  if (!PET_ID_REGEX.test(petId)) return false;
  ensureUserFolder(rootDir, ownerId);
  const dir = resolveUserSubdir(rootDir, ownerId, 'pets', petId);
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return false;
  renameSync(dir, trashPath(rootDir, ownerId, `${petId}-pet`));
  const settings = readPetSettings(rootDir, ownerId);
  const cleanedWorkerPets = Object.fromEntries(
    Object.entries(settings.workerPets).filter(([, mappedId]) => mappedId !== petId),
  );
  const workerPetsChanged =
    Object.keys(cleanedWorkerPets).length !== Object.keys(settings.workerPets).length;
  if (settings.activePetId === petId || workerPetsChanged) {
    writePetSettings(rootDir, ownerId, {
      activePetId: settings.activePetId === petId ? null : settings.activePetId,
      workerPets: cleanedWorkerPets,
    });
  }
  return true;
};

export const resolvePetAsset = (
  rootDir: string,
  ownerId: string,
  petId: string,
  file: string,
): { path: string; contentType: string } | null => {
  if (!PET_ID_REGEX.test(petId)) return null;
  if (!file || file.includes('/') || file.includes('\\') || file.startsWith('.') || file.includes('\0')) return null;
  const ext = extname(file).toLowerCase();
  const contentType =
    ext === '.png' ? 'image/png' :
    ext === '.webp' ? 'image/webp' :
    ext === '.json' ? 'application/json; charset=utf-8' :
    null;
  if (!contentType) return null;
  const path = resolveUserSubdir(rootDir, ownerId, 'pets', `${petId}/${file}`);
  if (!existsSync(path) || !statSync(path).isFile()) return null;
  return { path, contentType };
};
