/**
 * App Store Manager
 * Fetches, caches, and serves app templates from the remote App Store repo.
 */

import * as plugins from '../plugins.js';
import { logger } from '../logging.js';
import { getErrorMessage } from '../utils/error.js';
import type { Onebox } from './onebox.js';
import type {
  IPlatformRequirements,
  IService,
  IServiceDomainRoute,
  IServicePublicPortMapping,
  IServicePublishedPort,
  IServiceTargetPort,
  IServiceVolume,
} from '../types.js';
import { projectInfo } from '../info.js';
import type * as interfaces from '../../ts_interfaces/index.js';

type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest & {
  autoDNS?: boolean;
  volumeStorageClasses?: Record<string, string>;
};
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;

interface IResolvedAppStoreInputs {
  publicEnvVars: Record<string, string>;
  secretEnvVars: Record<string, string>;
}

export interface IAppStoreManagerOptions {
  source?: 'package' | 'remote';
  baseUrl?: string;
  fetch?: typeof fetch;
  resolveDockerDigests?: boolean;
}

export interface IMigrationContext {
  service: {
    name: string;
    image: string;
    envVars: Record<string, string>;
    port: number;
    targetPorts?: IServiceTargetPort[];
    domains?: IServiceDomainRoute[];
    publicPortMappings?: IServicePublicPortMapping[];
  };
  fromVersion: string;
  toVersion: string;
}

export interface IMigrationResult {
  success: boolean;
  envVars?: Record<string, string>;
  image?: string;
  imageDigest?: string;
  port?: number;
  volumes?: IServiceVolume[];
  publishedPorts?: IServicePublishedPort[];
  targetPorts?: IServiceTargetPort[];
  domains?: IServiceDomainRoute[];
  publicPortMappings?: IServicePublicPortMapping[];
  warnings: string[];
}

export interface IAppStoreUpgradeApplyOptions {
  onProgress?: (progressArg: { step: string; message: string }) => void | Promise<void>;
}

export class AppStoreManager {
  private appStoreCache: IAppStoreIndex | null = null;
  private appStoreResolver: plugins.servezoneAppstore.AppStoreResolver;
  private lastFetchTime = 0;
  private readonly appStoreSource: 'package' | 'remote';
  private readonly appStoreBaseUrl: string;
  private readonly fetchRef: typeof fetch;
  private readonly resolveDockerDigests: boolean;
  private readonly cacheTtlMs = 5 * 60 * 1000;

  constructor(
    private oneboxRef: Onebox,
    optionsArg: IAppStoreManagerOptions = {},
  ) {
    this.appStoreSource = optionsArg.source || (optionsArg.baseUrl || optionsArg.fetch ? 'remote' : 'package');
    this.appStoreBaseUrl = optionsArg.baseUrl || 'https://code.foss.global/serve.zone/appstore/raw/branch/main';
    this.fetchRef = optionsArg.fetch || fetch;
    this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true;
    this.appStoreResolver = this.createAppStoreResolver();
  }

  public async init(): Promise<void> {
    try {
      await this.getAppStore();
      logger.info(`App Store initialized with ${this.appStoreCache?.apps.length || 0} templates`);
    } catch (error) {
      logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`);
      logger.warn('App Store will retry on next request');
    }
  }

  public async getAppStore(): Promise<IAppStoreIndex> {
    const now = Date.now();
    if (this.appStoreCache && (now - this.lastFetchTime) < this.cacheTtlMs) {
      return this.appStoreCache;
    }

    try {
      const resolver = this.createAppStoreResolver();
      const appStore = await resolver.getAppStoreIndex();
      this.appStoreResolver = resolver;
      this.appStoreCache = appStore;
      this.lastFetchTime = now;
      return appStore;
    } catch (error) {
      logger.warn(`Failed to fetch remote App Store: ${getErrorMessage(error)}`);
      if (this.appStoreCache) {
        return this.appStoreCache;
      }
      return { schemaVersion: 1, updatedAt: '', apps: [] };
    }
  }

  public async getApps(): Promise<IAppStoreApp[]> {
    return (await this.getAppStore()).apps;
  }

  public getSourceInfo(): interfaces.requests.IAppStoreSourceInfo {
    return this.appStoreResolver.getSourceInfo();
  }

  public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
    try {
      await this.getAppStore();
      return await this.appStoreResolver.getAppMeta(appIdArg);
    } catch (error) {
      throw new Error(`Failed to fetch metadata for app '${appIdArg}': ${getErrorMessage(error)}`);
    }
  }

  public async getAppVersionConfig(
    appIdArg: string,
    versionArg?: string,
  ): Promise<IAppStoreVersionConfig> {
    try {
      const version = versionArg || (await this.getAppMeta(appIdArg)).latestVersion;
      await this.getAppStore();
      return await this.appStoreResolver.getAppVersionConfig(appIdArg, version);
    } catch (error) {
      throw new Error(`Failed to fetch config for ${appIdArg}@${versionArg || 'latest'}: ${getErrorMessage(error)}`);
    }
  }

  public async installApp(optionsArg: IAppStoreInstallOptions): Promise<IService> {
    this.validateInstallOptions(optionsArg);
    const appMeta = await this.getAppMeta(optionsArg.appId);
    const version = optionsArg.version || appMeta.latestVersion;
    const config = await this.getAppVersionConfig(optionsArg.appId, version);
    const appStoreVersion = config.appStoreVersion || version;
    this.assertRuntimeCompatibility(config);
    const servicePort = this.getAppStoreWebPort(config, optionsArg.port);
    this.assertValidPort(servicePort, 'install service port');
    const targetPorts = this.getAppStoreTargetPorts(config, servicePort, optionsArg.port !== undefined);
    const domains = this.getInstallDomains(config, optionsArg.domain);
    const publicPortMappings = structuredClone(optionsArg.publicPortMappings || config.publicPortMappings || []);
    const volumes = this.normalizeVolumes(config.volumes).map((volumeArg) => {
      const storageKey = volumeArg.source || volumeArg.name || volumeArg.mountPath;
      const storageClass = optionsArg.volumeStorageClasses?.[storageKey];
      return storageClass ? { ...volumeArg, storageClass } : volumeArg;
    });
    const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || [];
    this.validateAppVersionConfig(
      { ...config, port: servicePort, targetPorts, domains, publicPortMappings, publishedPorts },
      `${optionsArg.appId}@${version} install`,
    );

    const inputs = this.getAppStoreInputs(config, optionsArg.envVars || {});
    if (this.requiresTemplateValue(inputs.publicEnvVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
      throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
    }
    this.applyServiceDomainEnv(inputs.publicEnvVars, optionsArg.domain);

    const platformRequirements = config.platformRequirements as IPlatformRequirements | undefined;

    return await this.oneboxRef.services.deployService({
      name: optionsArg.serviceName,
      image: config.image,
      port: servicePort,
      domain: domains?.[0]?.name,
      autoDNS: optionsArg.autoDNS,
      envVars: inputs.publicEnvVars,
      secretEnvVars: inputs.secretEnvVars,
      containerArgs: config.containerArgs,
      secretFiles: config.secretFiles,
      volumes,
      publishedPorts,
      targetPorts,
      domains,
      publicPortMappings,
      enableMongoDB: Boolean(platformRequirements?.mongodb),
      enableS3: Boolean(platformRequirements?.s3),
      enableClickHouse: Boolean(platformRequirements?.clickhouse),
      enableValkey: Boolean(platformRequirements?.valkey),
      enableMariaDB: Boolean(platformRequirements?.mariadb),
      appTemplateId: optionsArg.appId,
      appTemplateVersion: appStoreVersion,
      imageDigest: config.resolvedImageDigest,
    });
  }

  public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
    const appStore = await this.getAppStore();
    const services = await this.oneboxRef.database.getAllServices();
    const upgradeable: IUpgradeableAppStoreService[] = [];

    for (const service of services) {
      if (!service.appTemplateId || !service.appTemplateVersion) continue;

      const appStoreApp = appStore.apps.find((appArg: IAppStoreApp) => appArg.id === service.appTemplateId);
      if (!appStoreApp || appStoreApp.latestVersion === service.appTemplateVersion) continue;

      upgradeable.push({
        serviceName: service.name,
        appTemplateId: service.appTemplateId,
        currentVersion: service.appTemplateVersion,
        latestVersion: appStoreApp.latestVersion,
        hasMigration: await this.hasMigrationScript(
          service.appTemplateId,
          service.appTemplateVersion,
          appStoreApp.latestVersion,
        ),
      });
    }

    return upgradeable;
  }

  public async hasMigrationScript(
    appIdArg: string,
    fromVersionArg: string,
    toVersionArg: string,
  ): Promise<boolean> {
    try {
      await this.fetchText(`apps/${appIdArg}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`);
      return true;
    } catch {
      return false;
    }
  }

  public async executeMigration(
    serviceArg: IService,
    fromVersionArg: string,
    toVersionArg: string,
  ): Promise<IMigrationResult> {
    const appId = serviceArg.appTemplateId;
    if (!appId) {
      throw new Error('Service has no appTemplateId');
    }

    const scriptPath = `apps/${appId}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`;
    let scriptContent: string;
    try {
      scriptContent = await this.fetchText(scriptPath);
    } catch {
      logger.info(`No migration script for ${appId} ${fromVersionArg} -> ${toVersionArg}, using config-only upgrade`);
      const config = await this.getAppVersionConfig(appId, toVersionArg);
      const port = this.getAppStoreWebPort(config);
      return {
        success: true,
        image: config.image,
        imageDigest: config.resolvedImageDigest,
        port,
        volumes: this.normalizeVolumes(config.volumes),
        publishedPorts: config.publishedPorts,
        targetPorts: this.getAppStoreTargetPorts(config, port),
        domains: this.getInstallDomains(config, serviceArg.domain),
        publicPortMappings: structuredClone(config.publicPortMappings || []),
        envVars: undefined,
        warnings: [],
      };
    }

    const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.mjs`;
    await plugins.fs.writeTextFile(tempFile, scriptContent);

    try {
      const context: IMigrationContext = {
        service: {
          name: serviceArg.name,
          image: serviceArg.image,
          envVars: serviceArg.envVars,
          port: serviceArg.port,
          targetPorts: serviceArg.targetPorts,
          domains: serviceArg.domains,
          publicPortMappings: serviceArg.publicPortMappings,
        },
        fromVersion: fromVersionArg,
        toVersion: toVersionArg,
      };

      const output = await plugins.runCommand(process.execPath, {
        args: [tempFile],
        stdin: 'piped',
        stdout: 'piped',
        stderr: 'piped',
        input: new TextEncoder().encode(JSON.stringify(context)),
      });
      const stdout = new TextDecoder().decode(output.stdout);
      const stderr = new TextDecoder().decode(output.stderr);

      if (output.code !== 0) {
        logger.error(`Migration script failed (exit ${output.code}): ${stderr.substring(0, 500)}`);
        return {
          success: false,
          warnings: [`Migration script failed: ${stderr.substring(0, 200)}`],
        };
      }

      try {
        const result = JSON.parse(stdout) as IMigrationResult;
        result.success = true;
        return result;
      } catch {
        logger.error(`Failed to parse migration output: ${stdout.substring(0, 200)}`);
        return {
          success: false,
          warnings: ['Migration script produced invalid output'],
        };
      }
    } finally {
      try {
        await plugins.fs.remove(tempFile);
      } catch {
        // Ignore cleanup errors.
      }
    }
  }

  public async applyUpgrade(
    serviceNameArg: string,
    migrationResultArg: IMigrationResult,
    newVersionArg: string,
    optionsArg: IAppStoreUpgradeApplyOptions = {},
  ): Promise<IService> {
    const service = await this.oneboxRef.database.getServiceByName(serviceNameArg);
    if (!service) {
      throw new Error(`Service not found: ${serviceNameArg}`);
    }

    const config = await this.getAppVersionConfig(service.appTemplateId!, newVersionArg);
    const existingSecrets = await this.oneboxRef.database.serviceSecrets.get(service.id!);
    const inputs = this.getAppStoreInputs(config, {}, service.envVars, existingSecrets);
    const updates: Partial<Omit<IService, 'containerArgs' | 'secretFiles'>> & {
      secretEnvVars?: Record<string, string>;
      containerArgs?: string[] | null;
      secretFiles?: IService['secretFiles'] | null;
    } = {
      appTemplateVersion: newVersionArg,
      containerArgs: config.containerArgs ?? null,
      secretFiles: config.secretFiles ?? null,
      secretEnvVars: inputs.secretEnvVars,
    };

    if (migrationResultArg.image) {
      updates.image = migrationResultArg.image;
    }
    if (migrationResultArg.imageDigest !== undefined) {
      updates.imageDigest = migrationResultArg.imageDigest;
    }
    if (migrationResultArg.port) {
      updates.port = migrationResultArg.port;
    }
    if (migrationResultArg.volumes) {
      updates.volumes = migrationResultArg.volumes;
    }
    if (migrationResultArg.publishedPorts) {
      updates.publishedPorts = migrationResultArg.publishedPorts;
    }
    if (migrationResultArg.targetPorts !== undefined) {
      updates.targetPorts = migrationResultArg.targetPorts;
    }
    if (migrationResultArg.domains !== undefined) {
      updates.domains = migrationResultArg.domains;
    }
    if (migrationResultArg.publicPortMappings !== undefined) {
      updates.publicPortMappings = migrationResultArg.publicPortMappings;
    }
    updates.envVars = {
      ...service.envVars,
      ...inputs.publicEnvVars,
      ...(migrationResultArg.envVars || {}),
    };

    const updatedService = await this.oneboxRef.services.updateService(
      serviceNameArg,
      updates,
      {
        onProgress: async (progressArg) => {
          await optionsArg.onProgress?.(progressArg);
        },
      },
    );

    logger.success(`Service '${serviceNameArg}' upgraded to App Store version ${newVersionArg}`);
    return updatedService;
  }

  public normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []): IServiceVolume[] {
    return this.appStoreResolver.normalizeVolumes(volumesArg) as IServiceVolume[];
  }

  public validateAppVersionConfig(configArg: IAppStoreVersionConfig, labelArg = 'app config'): void {
    this.appStoreResolver.validateAppStoreVersionConfig(configArg, labelArg);
  }

  private createAppStoreResolver(): plugins.servezoneAppstore.AppStoreResolver {
    return new plugins.servezoneAppstore.AppStoreResolver({
      source: this.appStoreSource,
      baseUrl: this.appStoreBaseUrl,
      fetch: this.fetchRef,
      resolveDockerDigests: this.resolveDockerDigests,
    });
  }

  private async fetchText(pathArg: string): Promise<string> {
    return await this.appStoreResolver.fetchText(pathArg);
  }

  private validateInstallOptions(optionsArg: IAppStoreInstallOptions): void {
    if (!optionsArg.appId || !/^[a-z0-9][a-z0-9-]*$/.test(optionsArg.appId)) {
      throw new Error(`Invalid app id: ${optionsArg.appId}`);
    }
    if (!optionsArg.serviceName || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,119}$/.test(optionsArg.serviceName)) {
      throw new Error(`Invalid service name: ${optionsArg.serviceName}`);
    }
    if (optionsArg.port !== undefined) {
      this.assertValidPort(optionsArg.port, 'install service port');
    }
  }

  private assertValidPort(portArg: number, labelArg: string): void {
    if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
      throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`);
    }
  }

  private getAppStoreWebPort(configArg: IAppStoreVersionConfig, overridePortArg?: number): number {
    if (overridePortArg) return overridePortArg;
    const targetPorts = configArg.targetPorts || [];
    const target = targetPorts.find((targetArg) => targetArg.default)
      || targetPorts.find((targetArg) => targetArg.name === 'web')
      || targetPorts[0];
    if (!target?.port) {
      throw new Error('App Store config requires targetPorts');
    }
    return target.port;
  }

  private getAppStoreTargetPorts(
    configArg: IAppStoreVersionConfig,
    webPortArg: number,
    forceWebPortArg = false,
  ): IServiceTargetPort[] {
    const targetPorts = structuredClone(configArg.targetPorts || []);
    if (targetPorts.length === 0) {
      if (!forceWebPortArg) {
        throw new Error('App Store config requires targetPorts');
      }
      return [{ name: 'web', port: webPortArg, protocol: 'http', default: true }];
    }
    if (forceWebPortArg) {
      const target = targetPorts.find((targetArg) => targetArg.name === 'web')
        || targetPorts.find((targetArg) => targetArg.default);
      if (target && !target.portEnd) target.port = webPortArg;
    }
    return targetPorts;
  }

  private getInstallDomains(configArg: IAppStoreVersionConfig, domainArg?: string): IService['domains'] {
    const templateDomains = configArg.domains || [];
    if (templateDomains.length > 0) {
      return templateDomains.map((domainEntryArg) => ({
        ...domainEntryArg,
        name: this.replaceServiceDomainTemplate(domainEntryArg.name, domainArg),
      }));
    }
    const defaultTarget = this.getDefaultInstallDomainTarget(configArg);
    return domainArg && defaultTarget
      ? [{ name: domainArg, targetPort: defaultTarget.name || 'web', protocol: 'https' }]
      : [];
  }

  private getDefaultInstallDomainTarget(configArg: IAppStoreVersionConfig): IServiceTargetPort | { name: 'web' } | undefined {
    const targetPorts = configArg.targetPorts || [];
    const target = targetPorts.find((targetArg) => targetArg.default)
      || targetPorts.find((targetArg) => targetArg.name === 'web')
      || targetPorts.find((targetArg) => ['http', 'https'].includes(targetArg.protocol || ''));
    if (target) {
      return ['http', 'https'].includes(target.protocol || '') ? target : undefined;
    }
    return undefined;
  }

  private replaceServiceDomainTemplate(valueArg: string, domainArg?: string): string {
    if (!valueArg.includes('${SERVICE_DOMAIN}')) return valueArg;
    if (!domainArg) {
      throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
    }
    return valueArg.replace(/\$\{SERVICE_DOMAIN\}/g, domainArg);
  }

  private getAppStoreInputs(
    configArg: IAppStoreVersionConfig,
    overridesArg: Record<string, string>,
    existingPublicEnvVarsArg: Record<string, string> = {},
    existingSecretEnvVarsArg: Record<string, string> = {},
  ): IResolvedAppStoreInputs {
    const publicEnvVars: Record<string, string> = {};
    const secretEnvVars: Record<string, string> = { ...existingSecretEnvVarsArg };
    const missingRequiredEnvVars: string[] = [];
    const declaredSecretKeys = new Set<string>();

    for (const envVar of configArg.envVars || []) {
      if (envVar.secret) declaredSecretKeys.add(envVar.key);
      let value = envVar.secret
        ? overridesArg[envVar.key] ?? existingSecretEnvVarsArg[envVar.key] ?? ''
        : overridesArg[envVar.key] ?? existingPublicEnvVarsArg[envVar.key] ?? envVar.value ?? '';
      if (!value && envVar.generate) {
        value = this.generateAppStoreSecret(envVar.generate);
      }
      if (envVar.required && !value) {
        missingRequiredEnvVars.push(envVar.key);
      }
      if (envVar.secret) {
        if (value) secretEnvVars[envVar.key] = value;
      } else {
        publicEnvVars[envVar.key] = value;
      }
    }

    for (const [key, value] of Object.entries(overridesArg)) {
      if (declaredSecretKeys.has(key)) {
        if (value) secretEnvVars[key] = value;
      } else {
        publicEnvVars[key] = value;
      }
    }

    if (missingRequiredEnvVars.length > 0) {
      throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`);
    }

    return { publicEnvVars, secretEnvVars };
  }

  private generateAppStoreSecret(
    generationArg: NonNullable<plugins.servezoneInterfaces.appstore.IAppStoreEnvVar['generate']>,
  ): string {
    if (generationArg.type !== 'randomHex'
      || !Number.isInteger(generationArg.bytes)
      || generationArg.bytes < 16
      || generationArg.bytes > 64) {
      throw new Error('Invalid App Store secret generator');
    }
    return Array.from(crypto.getRandomValues(new Uint8Array(generationArg.bytes)))
      .map((byteArg) => byteArg.toString(16).padStart(2, '0'))
      .join('');
  }

  private requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
    return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
  }

  private applyServiceDomainEnv(envVarsArg: Record<string, string>, serviceDomainArg?: string): void {
    if (!serviceDomainArg) return;
    for (const [key, value] of Object.entries(envVarsArg)) {
      envVarsArg[key] = value.replace(/\$\{SERVICE_DOMAIN\}/g, serviceDomainArg);
    }
  }

  private assertRuntimeCompatibility(configArg: IAppStoreVersionConfig): void {
    if (!configArg.minOneboxVersion) return;
    if (this.compareVersions(projectInfo.version, configArg.minOneboxVersion) < 0) {
      throw new Error(
        `App requires Onebox >= ${configArg.minOneboxVersion}; current version is ${projectInfo.version}`,
      );
    }
  }

  private compareVersions(versionAArg: string, versionBArg: string): number {
    const normalize = (versionArg: string) => versionArg.replace(/^v/, '').split('.').map((partArg) => Number(partArg) || 0);
    const a = normalize(versionAArg);
    const b = normalize(versionBArg);
    for (let i = 0; i < Math.max(a.length, b.length); i++) {
      const diff = (a[i] || 0) - (b[i] || 0);
      if (diff !== 0) return diff > 0 ? 1 : -1;
    }
    return 0;
  }
}
