import path from 'path';

import YAML from 'yaml';

import { TENANT_HOME_BASE } from './config.js';

export interface TenantAppBuildSpec {
  context?: string;
  dockerfile?: string;
  args?: Record<string, string | number | boolean>;
}

export interface TenantAppServiceSpec {
  image?: string;
  build?: TenantAppBuildSpec;
  internal_port?: number;
  public?: boolean;
  environment?: Record<string, string | number | boolean> | string[];
  volumes?: string[];
  depends_on?: string[];
  command?: string | string[];
  entrypoint?: string | string[];
  sdk?: boolean;
  ports?: string[];
}

export interface TenantAppSpec {
  version?: number;
  services?: Record<string, TenantAppServiceSpec>;
  volumes?: Record<string, unknown>;
}

export interface NormalizedTenantAppVolume {
  type: 'bind' | 'named';
  sourcePath: string; // host path for bind mounts, volume name for named volumes
  targetPath: string;
  readonly: boolean;
  raw: string;
}

export interface NormalizedTenantAppBuild {
  contextPath: string;
  dockerfilePath: string;
  args: string[];
}

export interface NormalizedTenantAppService {
  name: string;
  image: string;
  build: NormalizedTenantAppBuild | null;
  internalPort: number | null;
  public: boolean;
  environment: string[];
  volumes: NormalizedTenantAppVolume[];
  dependsOn: string[];
  command: string | string[] | null;
  entrypoint: string | string[] | null;
  sdk: boolean;
}

export interface NormalizedTenantAppSpec {
  version: number;
  services: NormalizedTenantAppService[];
  publicService: NormalizedTenantAppService | null;
  namedVolumes: string[];
}

export interface TenantAppSpecValidationResult {
  valid: boolean;
  errors: string[];
  spec: NormalizedTenantAppSpec | null;
}

export interface NerdctlAppDeployPlan {
  tenantId: string;
  appName: string;
  appDir: string;
  projectName: string;
  hasBuildServices: boolean;
  composeFilePath: string;
  unitFilePath: string;
  unitName: string;
  publicServiceName: string | null;
  publicContainerPort: number | null;
  composeContent: string;
  unitContent: string;
}

function sanitizeSegment(value: string): string {
  return String(value || '')
    .toLowerCase()
    .replace(/[^a-z0-9-]/g, '-')
    .replace(/^-+/, '')
    .replace(/-+$/, '')
    .replace(/-+/g, '-');
}

export function getTenantAppSpecPath(tenantId: string, appName: string): string {
  return path.join(
    TENANT_HOME_BASE,
    tenantId,
    'workspace',
    'apps',
    sanitizeSegment(appName),
    'app.yml',
  );
}

export function getTenantLegacyAppSpecPath(
  tenantId: string,
  appName: string,
): string {
  return path.join(
    TENANT_HOME_BASE,
    tenantId,
    'workspace',
    'apps',
    sanitizeSegment(appName),
    'nanoclaw.app.yml',
  );
}

export function getTenantAppRuntimeDir(tenantId: string, appName: string): string {
  return path.join(
    TENANT_HOME_BASE,
    tenantId,
    '.config',
    'nanoclaw',
    'apps',
    sanitizeSegment(appName),
  );
}

export function getTenantAppUnitName(appName: string): string {
  return `nanoclaw-app-${sanitizeSegment(appName)}.service`;
}

export function getTenantAppUnitPath(tenantId: string, appName: string): string {
  return path.join(
    TENANT_HOME_BASE,
    tenantId,
    '.config',
    'systemd',
    'user',
    getTenantAppUnitName(appName),
  );
}

export function parseTenantAppSpec(raw: string): TenantAppSpec {
  const parsed = YAML.parse(raw);
  if (!parsed || typeof parsed !== 'object') {
    return {};
  }
  return parsed as TenantAppSpec;
}

function normalizeEnvironment(
  value: TenantAppServiceSpec['environment'],
  serviceName: string,
  errors: string[],
): string[] {
  if (value == null) return [];

  if (Array.isArray(value)) {
    return value
      .map((entry) => String(entry || '').trim())
      .filter(Boolean)
      .filter((entry) => {
        const ok = entry.includes('=');
        if (!ok) {
          errors.push(
            `Service "${serviceName}" has an invalid environment entry "${entry}". Use KEY=VALUE.`,
          );
        }
        return ok;
      });
  }

  if (typeof value !== 'object') {
    errors.push(`Service "${serviceName}" has an invalid environment block.`);
    return [];
  }

  return Object.entries(value).map(
    ([key, entry]) => `${key}=${String(entry ?? '')}`,
  );
}

function normalizeStringOrStringArray(
  value: string | string[] | undefined,
  serviceName: string,
  fieldName: 'command' | 'entrypoint',
  errors: string[],
): string | string[] | null {
  if (value == null) return null;

  if (typeof value === 'string') {
    const trimmed = value.trim();
    if (!trimmed) {
      errors.push(`Service "${serviceName}" has an empty ${fieldName}.`);
      return null;
    }
    return trimmed;
  }

  if (!Array.isArray(value)) {
    errors.push(`Service "${serviceName}" has an invalid ${fieldName}.`);
    return null;
  }

  const normalized = value
    .map((entry) => String(entry ?? '').trim())
    .filter(Boolean);
  if (normalized.length === 0) {
    errors.push(`Service "${serviceName}" has an empty ${fieldName}.`);
    return null;
  }
  return normalized;
}

function parseVolume(
  tenantId: string,
  appDir: string,
  serviceName: string,
  raw: string,
  errors: string[],
  allowedHostPaths: string[] = [],
): NormalizedTenantAppVolume | null {
  const parts = String(raw || '').split(':');
  if (parts.length < 2 || parts.length > 3) {
    errors.push(
      `Service "${serviceName}" has invalid volume "${raw}". Use source:target or source:target:ro.`,
    );
    return null;
  }

  const [sourceRaw, targetPath, modeRaw] = parts;
  const source = String(sourceRaw || '').trim();
  const target = String(targetPath || '').trim();
  const mode = String(modeRaw || '').trim();

  if (!source || !target.startsWith('/')) {
    errors.push(
      `Service "${serviceName}" has invalid volume "${raw}". Target paths must be absolute.`,
    );
    return null;
  }

  if (mode && mode !== 'ro' && mode !== 'rw') {
    errors.push(
      `Service "${serviceName}" has invalid volume mode "${mode}" in "${raw}".`,
    );
    return null;
  }

  // Named volume: source is a simple identifier (no path separators, not absolute, not relative)
  const isNamedVolume = !source.startsWith('/') && !source.startsWith('./') && !source.startsWith('../') && !source.includes(path.sep);
  if (isNamedVolume) {
    return {
      type: 'named',
      sourcePath: source,
      targetPath: target,
      readonly: mode === 'ro',
      raw,
    };
  }

  const resolvedSource = source.startsWith('/')
    ? path.resolve(source)
    : path.resolve(appDir, source);
  const allowedPrefix = path.join(TENANT_HOME_BASE, tenantId) + path.sep;

  // Check against allowed host paths (e.g. /opt/android-sdk for tenants with android_sdk)
  const isAllowedHostPath = allowedHostPaths.some(
    (allowed) => resolvedSource === allowed || resolvedSource.startsWith(allowed + path.sep),
  );

  if (
    !isAllowedHostPath &&
    !resolvedSource.startsWith(allowedPrefix) &&
    resolvedSource !== path.join(TENANT_HOME_BASE, tenantId)
  ) {
    errors.push(
      `Service "${serviceName}" volume "${raw}" resolves outside the tenant home.`,
    );
    return null;
  }

  // Force allowed host paths to be read-only
  if (isAllowedHostPath) {
    return {
      type: 'bind',
      sourcePath: resolvedSource,
      targetPath: target,
      readonly: true,
      raw,
    };
  }

  return {
    type: 'bind',
    sourcePath: resolvedSource,
    targetPath: target,
    readonly: mode === 'ro',
    raw,
  };
}

function normalizeBuild(
  tenantId: string,
  appDir: string,
  serviceName: string,
  build: TenantAppBuildSpec,
  errors: string[],
): NormalizedTenantAppBuild | null {
  if (!build || typeof build !== 'object') {
    errors.push(`Service "${serviceName}" has an invalid build block.`);
    return null;
  }

  const allowedBuildKeys = new Set(['context', 'dockerfile', 'args']);
  for (const key of Object.keys(build)) {
    if (!allowedBuildKeys.has(key)) {
      errors.push(`Service "${serviceName}" build uses unsupported field "${key}".`);
    }
  }

  const contextRaw = String(build.context || '.').trim() || '.';
  const dockerfileRaw = String(build.dockerfile || 'Dockerfile').trim() || 'Dockerfile';
  const contextPath = path.resolve(appDir, contextRaw);
  const dockerfilePath = path.resolve(appDir, dockerfileRaw);
  const allowedPrefix = path.join(TENANT_HOME_BASE, tenantId) + path.sep;
  const buildArgsRaw = build.args;
  let buildArgs: string[] = [];

  if (
    !contextPath.startsWith(allowedPrefix) &&
    contextPath !== path.join(TENANT_HOME_BASE, tenantId)
  ) {
    errors.push(
      `Service "${serviceName}" build context "${contextRaw}" resolves outside the tenant home.`,
    );
    return null;
  }

  if (
    !dockerfilePath.startsWith(allowedPrefix) &&
    dockerfilePath !== path.join(TENANT_HOME_BASE, tenantId)
  ) {
    errors.push(
      `Service "${serviceName}" dockerfile "${dockerfileRaw}" resolves outside the tenant home.`,
    );
    return null;
  }

  if (buildArgsRaw != null) {
    if (typeof buildArgsRaw !== 'object' || Array.isArray(buildArgsRaw)) {
      errors.push(`Service "${serviceName}" build args must be a key/value object.`);
    } else {
      buildArgs = Object.entries(buildArgsRaw).map(
        ([key, value]) => `${key}=${String(value ?? '')}`,
      );
    }
  }

  return {
    contextPath,
    dockerfilePath,
    args: buildArgs,
  };
}

export function validateTenantAppSpec(options: {
  tenantId: string;
  appName: string;
  appDir: string;
  rawSpec: string;
  requirePublicService?: boolean;
  allowedHostPaths?: string[];
  allowLegacyPorts?: boolean;
}): TenantAppSpecValidationResult {
  const {
    tenantId,
    rawSpec,
    appDir,
    requirePublicService = true,
    allowedHostPaths = [],
    allowLegacyPorts = true,
  } = options;
  const errors: string[] = [];

  let parsed: TenantAppSpec = {};
  try {
    parsed = parseTenantAppSpec(rawSpec);
  } catch (error: any) {
    return {
      valid: false,
      errors: [`Failed to parse app.yml: ${error.message}`],
      spec: null,
    };
  }

  const rawServices = parsed.services;
  if (!rawServices || typeof rawServices !== 'object') {
    return {
      valid: false,
      errors: ['App spec must define a top-level services object.'],
      spec: null,
    };
  }

  const allowedTopLevelKeys = new Set(['version', 'services', 'volumes']);
  for (const key of Object.keys(parsed)) {
    if (!allowedTopLevelKeys.has(key)) {
      errors.push(`App spec uses unsupported top-level field "${key}".`);
    }
  }

  // Collect declared named volumes from the top-level volumes section
  const declaredNamedVolumes = new Set<string>(
    parsed.volumes && typeof parsed.volumes === 'object'
      ? Object.keys(parsed.volumes).map((v) => v.trim()).filter(Boolean)
      : [],
  );

  const normalized: NormalizedTenantAppService[] = [];
  for (const [rawServiceName, service] of Object.entries(rawServices)) {
    const serviceName = sanitizeSegment(rawServiceName);
    if (!serviceName) {
      errors.push(`Service name "${rawServiceName}" is invalid.`);
      continue;
    }

    if (!service || typeof service !== 'object') {
      errors.push(`Service "${rawServiceName}" is invalid.`);
      continue;
    }

    const allowedServiceKeys = new Set([
      'image',
      'build',
      'internal_port',
      'public',
      'environment',
      'volumes',
      'depends_on',
      'command',
      'entrypoint',
      'sdk',
      ...(allowLegacyPorts ? ['ports'] : []),
    ]);
    for (const key of Object.keys(service)) {
      if (!allowedServiceKeys.has(key)) {
        errors.push(`Service "${serviceName}" uses unsupported field "${key}".`);
      }
    }
    if (!allowLegacyPorts && service.ports != null) {
      errors.push(
        `Service "${serviceName}" cannot define ports. Host port bindings are platform-managed.`,
      );
    }

    const hasImage = typeof service.image === 'string' && service.image.trim() !== '';
    const hasBuild = !!service.build;
    if (hasImage === hasBuild) {
      errors.push(
        `Service "${serviceName}" must define exactly one of image or build.`,
      );
      continue;
    }

    const internalPortValue = service.internal_port;
    let internalPort: number | null = null;
    if (internalPortValue != null) {
      internalPort = Number(internalPortValue);
      if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort > 65535) {
        errors.push(`Service "${serviceName}" has invalid internal_port "${internalPortValue}".`);
        internalPort = null;
      }
    }

    const isPublic = service.public === true;
    if (isPublic && internalPort == null) {
      errors.push(`Public service "${serviceName}" must define internal_port.`);
    }

    const environment = normalizeEnvironment(service.environment, serviceName, errors);
    const rawVolumes = service.volumes == null ? [] : service.volumes;
    if (!Array.isArray(rawVolumes)) {
      errors.push(`Service "${serviceName}" has an invalid volumes block.`);
    }
    const volumes = (Array.isArray(rawVolumes) ? rawVolumes : [])
      .map((entry) => parseVolume(tenantId, appDir, serviceName, entry, errors, allowedHostPaths))
      .filter((entry): entry is NormalizedTenantAppVolume => entry != null);
    for (const vol of volumes) {
      if (vol.type === 'named' && !declaredNamedVolumes.has(vol.sourcePath)) {
        errors.push(
          `Service "${serviceName}" references named volume "${vol.sourcePath}" not declared in top-level volumes.`,
        );
      }
    }
    const rawDependsOn = service.depends_on == null ? [] : service.depends_on;
    if (!Array.isArray(rawDependsOn)) {
      errors.push(`Service "${serviceName}" has an invalid depends_on block.`);
    }
    const dependsOn = (Array.isArray(rawDependsOn) ? rawDependsOn : [])
      .map((entry) => sanitizeSegment(entry))
      .filter(Boolean);
    const command = normalizeStringOrStringArray(
      service.command,
      serviceName,
      'command',
      errors,
    );
    const entrypoint = normalizeStringOrStringArray(
      service.entrypoint,
      serviceName,
      'entrypoint',
      errors,
    );

    const build = service.build
      ? normalizeBuild(tenantId, appDir, serviceName, service.build, errors)
      : null;

    normalized.push({
      name: serviceName,
      image: hasImage ? String(service.image).trim() : '',
      build,
      internalPort,
      public: isPublic,
      environment,
      volumes,
      dependsOn,
      command,
      entrypoint,
      sdk: service.sdk === true,
    });
  }

  if (normalized.length === 0) {
    errors.push('App spec does not contain any valid services.');
  }

  const serviceNames = new Set(normalized.map((service) => service.name));
  for (const service of normalized) {
    for (const dependency of service.dependsOn) {
      if (!serviceNames.has(dependency)) {
        errors.push(
          `Service "${service.name}" depends on unknown service "${dependency}".`,
        );
      }
    }
  }

  const publicServices = normalized.filter((service) => service.public);
  if (publicServices.length > 1) {
    errors.push('App spec may define at most one public service.');
  }
  if (requirePublicService && publicServices.length !== 1) {
    errors.push('Proxy app specs must define exactly one public service.');
  }

  return {
    valid: errors.length === 0,
    errors,
    spec: errors.length
      ? null
      : {
          version: Number(parsed.version || 1),
          services: normalized,
          publicService: publicServices[0] || null,
          namedVolumes: [...declaredNamedVolumes],
        },
  };
}

function buildComposeService(
  tenantId: string,
  appName: string,
  hostPort: number,
  service: NormalizedTenantAppService,
  sdkContext?: SdkContext,
): Record<string, unknown> {
  const unitBase = `${sanitizeSegment(tenantId)}-${sanitizeSegment(appName)}-${service.name}`;
  const imageTag = service.build
    ? `localhost/nanoclaw-${unitBase}:latest`
    : service.image;
  const composeService: Record<string, unknown> = {
    image: imageTag,
    restart: 'unless-stopped',
    labels: {
      'io.nanoclaw.tenant': tenantId,
      'io.nanoclaw.app': sanitizeSegment(appName),
      'io.nanoclaw.service': service.name,
    },
  };

  if (service.build) {
    const dockerfilePath = service.build.dockerfilePath.startsWith(
      `${service.build.contextPath}${path.sep}`,
    )
      ? path.relative(service.build.contextPath, service.build.dockerfilePath)
      : service.build.dockerfilePath;
    composeService.build = {
      context: service.build.contextPath,
      dockerfile: dockerfilePath,
      ...(service.build.args.length > 0 ? { args: service.build.args } : {}),
    };
  }
  const serviceEnvironment = [...service.environment];
  if (service.sdk && sdkContext) {
    serviceEnvironment.push(`TENANT_ID=${sdkContext.tenantId}`);
    serviceEnvironment.push(`NANOCLAW_GROUP_FOLDER=${sdkContext.groupFolder}`);
    serviceEnvironment.push(`NANOCLAW_CHAT_JID=${sdkContext.chatJid}`);
  }
  if (serviceEnvironment.length > 0) {
    composeService.environment = serviceEnvironment;
  }
  if (service.command != null) {
    composeService.command = service.command;
  }
  if (service.entrypoint != null) {
    composeService.entrypoint = service.entrypoint;
  }
  const serviceVolumes = service.volumes.map((volume) => {
    const suffix = volume.readonly ? ':ro' : '';
    return `${volume.sourcePath}:${volume.targetPath}${suffix}`;
  });
  if (service.sdk && sdkContext) {
    serviceVolumes.push(`${sdkContext.sdkHostPath}:/opt/treehouse/sdk:ro`);
    serviceVolumes.push(`${sdkContext.ipcHostPath}:/workspace/ipc`);
  }
  if (serviceVolumes.length > 0) {
    composeService.volumes = serviceVolumes;
  }
  // Note: named volumes use the same `name:target` format as bind mounts in the volumes array;
  // the top-level volumes section is added in buildComposeContent()
  if (service.dependsOn.length > 0) {
    composeService.depends_on = service.dependsOn;
  }
  if (service.internalPort != null) {
    if (service.public) {
      composeService.ports = [`127.0.0.1:${hostPort}:${service.internalPort}`];
    } else {
      composeService.expose = [String(service.internalPort)];
    }
  }

  return composeService;
}

function buildComposeContent(options: {
  tenantId: string;
  appName: string;
  hostPort: number;
  spec: NormalizedTenantAppSpec;
  projectName: string;
  sdkContext?: SdkContext;
}): string {
  const { tenantId, appName, hostPort, spec, projectName, sdkContext } = options;
  const services: Record<string, unknown> = {};
  for (const service of spec.services) {
    services[service.name] = buildComposeService(
      tenantId,
      appName,
      hostPort,
      service,
      sdkContext,
    );
  }

  const doc: Record<string, unknown> = {
    services,
    networks: {
      default: {
        name: `${projectName}-default`,
      },
    },
  };

  if (spec.namedVolumes.length > 0) {
    const volumes: Record<string, { name: string }> = {};
    for (const volName of spec.namedVolumes) {
      volumes[volName] = { name: `${projectName}-${volName}` };
    }
    doc.volumes = volumes;
  }

  return YAML.stringify(doc);
}

function buildSystemdUnit(options: {
  tenantId: string;
  appName: string;
  projectName: string;
  composeFilePath: string;
}): string {
  const { tenantId, appName, projectName, composeFilePath } = options;
  const runtimeDir = '/run/user/%U';
  return [
    '[Unit]',
    `Description=NanoClaw app ${tenantId}/${appName}`,
    'Requires=nanoclaw-containerd.service',
    'After=nanoclaw-containerd.service network-online.target',
    'Wants=network-online.target',
    '',
    '[Service]',
    'Type=oneshot',
    'RemainAfterExit=yes',
    'Environment=PATH=/run/wrappers/bin:/run/current-system/sw/bin:/usr/bin:/bin',
    `Environment=CONTAINERD_ADDRESS=unix://${runtimeDir}/containerd/containerd.sock`,
    `Environment=ROOTLESSKIT_STATE_DIR=${runtimeDir}/containerd-rootless`,
    `ExecStartPre=-/bin/sh -c 'timeout 15 /run/current-system/sw/bin/nerdctl --address unix://${runtimeDir}/containerd/containerd.sock compose --project-name ${projectName} -f ${composeFilePath} rm -f 2>/dev/null; exit 0'`,
    `ExecStart=/run/current-system/sw/bin/nerdctl --address unix://${runtimeDir}/containerd/containerd.sock compose --project-name ${projectName} -f ${composeFilePath} up -d --remove-orphans`,
    `ExecStop=/bin/sh -c 'timeout 30 /run/current-system/sw/bin/nerdctl --address unix://${runtimeDir}/containerd/containerd.sock compose --project-name ${projectName} -f ${composeFilePath} down --remove-orphans 2>/dev/null || true'`,
    `ExecReload=/bin/sh -c 'timeout 15 /run/current-system/sw/bin/nerdctl --address unix://${runtimeDir}/containerd/containerd.sock compose --project-name ${projectName} -f ${composeFilePath} rm -f 2>/dev/null || true; /run/current-system/sw/bin/nerdctl --address unix://${runtimeDir}/containerd/containerd.sock compose --project-name ${projectName} -f ${composeFilePath} up -d --remove-orphans'`,
    "TimeoutStartSec=0",
    "TimeoutStopSec=60",
    '',
    '[Install]',
    'WantedBy=default.target',
    '',
  ].join('\n');
}

export interface SdkContext {
  sdkHostPath: string;
  ipcHostPath: string;
  groupFolder: string;
  chatJid: string;
  tenantId: string;
}

export function buildNerdctlAppDeployPlan(options: {
  tenantId: string;
  appName: string;
  appDir: string;
  hostPort: number;
  spec: NormalizedTenantAppSpec;
  sdkContext?: SdkContext;
}): NerdctlAppDeployPlan {
  const { tenantId, appName, appDir, hostPort, spec, sdkContext } = options;
  const projectName = `nanoclaw-${sanitizeSegment(tenantId)}-${sanitizeSegment(appName)}`;
  const runtimeDir = getTenantAppRuntimeDir(tenantId, appName);
  const composeFilePath = path.join(runtimeDir, 'compose.yml');
  const unitFilePath = getTenantAppUnitPath(tenantId, appName);
  const unitName = getTenantAppUnitName(appName);
  const composeContent = buildComposeContent({
    tenantId,
    appName,
    hostPort,
    spec,
    projectName,
    sdkContext,
  });
  const unitContent = buildSystemdUnit({
    tenantId,
    appName,
    projectName,
    composeFilePath,
  });

  return {
    tenantId,
    appName,
    appDir,
    projectName,
    hasBuildServices: spec.services.some((service) => service.build != null),
    composeFilePath,
    unitFilePath,
    unitName,
    publicServiceName: spec.publicService?.name || null,
    publicContainerPort: spec.publicService?.internalPort || null,
    composeContent,
    unitContent,
  };
}
