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

import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
import type { IService, IServicePublishedPort, IServiceVolume } from '../types.ts';
import { projectInfo } from '../info.ts';

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;
};
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;

export interface IAppStoreManagerOptions {
  baseUrl?: string;
  fetch?: typeof fetch;
  resolveDockerDigests?: boolean;
}

export interface IMigrationContext {
  service: {
    name: string;
    image: string;
    envVars: Record<string, string>;
    port: number;
  };
  fromVersion: string;
  toVersion: string;
}

export interface IMigrationResult {
  success: boolean;
  envVars?: Record<string, string>;
  image?: string;
  imageDigest?: string;
  port?: number;
  volumes?: IServiceVolume[];
  publishedPorts?: IServicePublishedPort[];
  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 appStoreBaseUrl: string;
  private readonly fetchRef: typeof fetch;
  private readonly resolveDockerDigests: boolean;
  private readonly cacheTtlMs = 5 * 60 * 1000;

  constructor(
    private oneboxRef: Onebox,
    optionsArg: IAppStoreManagerOptions = {},
  ) {
    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 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 = optionsArg.port || config.port;
    this.assertValidPort(servicePort, 'install service port');
    const volumes = this.normalizeVolumes(config.volumes);
    const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || [];
    this.validateAppVersionConfig(
      { ...config, port: servicePort, publishedPorts },
      `${optionsArg.appId}@${version} install`,
    );

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

    return await this.oneboxRef.services.deployService({
      name: optionsArg.serviceName,
      image: config.image,
      port: servicePort,
      domain: optionsArg.domain,
      autoDNS: optionsArg.autoDNS,
      envVars,
      volumes,
      publishedPorts,
      enableMongoDB: Boolean(config.platformRequirements?.mongodb),
      enableS3: Boolean(config.platformRequirements?.s3),
      enableClickHouse: Boolean(config.platformRequirements?.clickhouse),
      enableRedis: Boolean(config.platformRequirements?.redis),
      enableMariaDB: Boolean(config.platformRequirements?.mariadb),
      appTemplateId: optionsArg.appId,
      appTemplateVersion: appStoreVersion,
      imageDigest: config.resolvedImageDigest,
    });
  }

  public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
    const appStore = await this.getAppStore();
    const services = 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);
      return {
        success: true,
        image: config.image,
        imageDigest: config.resolvedImageDigest,
        port: config.port,
        volumes: this.normalizeVolumes(config.volumes),
        publishedPorts: config.publishedPorts,
        envVars: undefined,
        warnings: [],
      };
    }

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

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

      const cmd = new Deno.Command('deno', {
        args: ['run', '--allow-env', '--allow-net=none', '--allow-read=none', '--allow-write=none', tempFile],
        stdin: 'piped',
        stdout: 'piped',
        stderr: 'piped',
      });

      const child = cmd.spawn();
      const writer = child.stdin.getWriter();
      await writer.write(new TextEncoder().encode(JSON.stringify(context)));
      await writer.close();

      const output = await child.output();
      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 Deno.remove(tempFile);
      } catch {
        // Ignore cleanup errors.
      }
    }
  }

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

    const updates: Partial<IService> = {
      appTemplateVersion: newVersionArg,
    };

    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.envVars) {
      const mergedEnvVars = { ...migrationResultArg.envVars };
      for (const [key, value] of Object.entries(service.envVars)) {
        if (!(key in mergedEnvVars)) {
          mergedEnvVars[key] = value;
        }
      }
      updates.envVars = mergedEnvVars;
    }

    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({
      baseUrl: this.appStoreBaseUrl,
      fetch: this.fetchRef,
      resolveDockerDigests: this.resolveDockerDigests,
    });
  }

  private async fetchText(pathArg: string): Promise<string> {
    const url = `${this.appStoreBaseUrl}/${pathArg}`;
    const response = await this.fetchRef(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status} for ${url}`);
    }
    return response.text();
  }

  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 getAppStoreEnvVars(
    configArg: IAppStoreVersionConfig,
    overridesArg: Record<string, string>,
  ): Record<string, string> {
    const envVars: Record<string, string> = {};
    const missingRequiredEnvVars: string[] = [];

    for (const envVar of configArg.envVars || []) {
      const value = overridesArg[envVar.key] ?? envVar.value ?? '';
      if (envVar.required && !value) {
        missingRequiredEnvVars.push(envVar.key);
      }
      envVars[envVar.key] = value;
    }

    Object.assign(envVars, overridesArg);

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

    return envVars;
  }

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

  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;
  }
}
