import { existsSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import AdmZip from 'adm-zip';
import { getCacheManager } from '../cache/cache-manager.js';
import { getFabricMaven } from '../downloaders/fabric-maven.js';
import { getMojangDownloader } from '../downloaders/mojang-downloader.js';
import { getMappingIO } from '../java/mapping-io.js';
import { parseTinyV2 } from '../parsers/tiny-v2.js';
import type { MappingType } from '../types/minecraft.js';
import { MappingNotFoundError } from '../utils/errors.js';
import { ensureDir } from '../utils/file-utils.js';
import { logger } from '../utils/logger.js';
import { getMojmapTinyPath } from '../utils/paths.js';
import { getVersionManager } from './version-manager.js';

/**
 * Manages mapping downloads and caching
 */
export class MappingService {
  private mojangDownloader = getMojangDownloader();
  private fabricMaven = getFabricMaven();
  private cache = getCacheManager();
  private versionManager = getVersionManager();

  // Lock to prevent concurrent downloads of the same mappings
  private downloadLocks = new Map<string, Promise<string>>();

  /**
   * Get or download mappings for a version.
   * Flow: cache → dedupe lock → unobfuscated guard → recheck lock → download.
   */
  async getMappings(version: string, mappingType: MappingType): Promise<string> {
    const lockKey = `${version}-${mappingType}`;

    // 1. Return immediately from cache without any network access
    const cachedPath = this.getCachedMapping(version, mappingType);
    if (cachedPath) {
      logger.info(`Using cached ${mappingType} mappings for ${version}: ${cachedPath}`);
      return cachedPath;
    }

    // 2. Deduplicate concurrent downloads for the same version+type
    const existingDownload = this.downloadLocks.get(lockKey);
    if (existingDownload) {
      logger.info(`Waiting for existing ${mappingType} download of ${version} to complete`);
      return existingDownload;
    }

    // 3. Unobfuscated versions (26.1+) have no mapping files — check before attempting download
    await this.throwIfUnobfuscated(version, mappingType);

    // 4. Recheck lock — another caller may have started a download during the async check above
    const postCheckDownload = this.downloadLocks.get(lockKey);
    if (postCheckDownload) {
      return postCheckDownload;
    }

    // 5. Download with lock
    logger.info(`Downloading ${mappingType} mappings for ${version}`);
    const downloadPromise = this.startDownload(version, mappingType);
    this.downloadLocks.set(lockKey, downloadPromise);
    try {
      return await downloadPromise;
    } finally {
      this.downloadLocks.delete(lockKey);
    }
  }

  /**
   * Check for a locally cached mapping file without hitting the network.
   */
  private getCachedMapping(version: string, mappingType: MappingType): string | null {
    if (mappingType === 'mojmap') {
      const convertedPath = getMojmapTinyPath(version);
      return existsSync(convertedPath) ? convertedPath : null;
    }
    return this.cache.getMappingPath(version, mappingType) ?? null;
  }

  /**
   * Start the actual download for a mapping type.
   * Mojmap handles its own caching internally; yarn/intermediary are cached here.
   */
  private async startDownload(version: string, mappingType: MappingType): Promise<string> {
    switch (mappingType) {
      case 'mojmap':
        return this.downloadAndConvertMojmap(version);
      case 'yarn': {
        const path = await this.downloadAndExtractYarn(version);
        this.cache.cacheMapping(version, mappingType, path);
        return path;
      }
      case 'intermediary': {
        const path = await this.downloadAndExtractIntermediary(version);
        this.cache.cacheMapping(version, mappingType, path);
        return path;
      }
      default:
        throw new MappingNotFoundError(
          version,
          mappingType,
          `Unsupported mapping type: ${mappingType}`,
        );
    }
  }

  /**
   * Download and extract Yarn mappings from JAR
   */
  private async downloadAndExtractYarn(version: string): Promise<string> {
    const jarPath = await this.fabricMaven.downloadYarnMappings(version);

    // Extract mappings.tiny from the JAR
    const zip = new AdmZip(jarPath);
    const mappingEntry = zip.getEntry('mappings/mappings.tiny');

    if (!mappingEntry) {
      throw new MappingNotFoundError(version, 'yarn', 'mappings.tiny not found in Yarn JAR');
    }

    // Save extracted mappings
    const extractedPath = jarPath.replace('.jar', '.tiny');
    const content = mappingEntry.getData();
    ensureDir(dirname(extractedPath));
    writeFileSync(extractedPath, content);

    logger.info(`Extracted Yarn mappings to ${extractedPath}`);
    return extractedPath;
  }

  /**
   * Download and extract Intermediary mappings from JAR
   */
  private async downloadAndExtractIntermediary(version: string): Promise<string> {
    const jarPath = await this.fabricMaven.downloadIntermediaryMappings(version);

    // Extract mappings.tiny from the JAR
    const zip = new AdmZip(jarPath);
    const mappingEntry = zip.getEntry('mappings/mappings.tiny');

    if (!mappingEntry) {
      throw new MappingNotFoundError(
        version,
        'intermediary',
        'mappings.tiny not found in Intermediary JAR',
      );
    }

    // Save extracted mappings
    const extractedPath = jarPath.replace('.jar', '.tiny');
    const content = mappingEntry.getData();
    ensureDir(dirname(extractedPath));
    writeFileSync(extractedPath, content);

    logger.info(`Extracted Intermediary mappings to ${extractedPath}`);
    return extractedPath;
  }

  /**
   * Download Mojang mappings and convert from ProGuard to Tiny v2 format
   *
   * Mojang mappings use ProGuard format which tiny-remapper cannot read directly.
   * This method uses mapping-io to properly merge ProGuard + Intermediary mappings:
   *
   * 1. Downloads raw ProGuard mappings from Mojang (named → obfuscated)
   * 2. Downloads Intermediary mappings (official → intermediary)
   * 3. Uses mapping-io to merge them and produce Tiny v2 (intermediary → named)
   *
   * The output file has proper Tiny v2 format with fields/methods nested under
   * their parent classes, which is required for tiny-remapper to work correctly.
   */
  private async downloadAndConvertMojmap(version: string): Promise<string> {
    logger.info(`Converting Mojmap for ${version} using mapping-io`);

    // Step 1: Download raw Mojang ProGuard mappings
    const proguardPath = await this.mojangDownloader.downloadMojangMappings(version);

    // Step 2: Get Intermediary mappings (download if needed)
    const intermediaryPath = await this.downloadAndExtractIntermediary(version);

    // Step 3: Convert using mapping-io (produces proper Tiny v2)
    const outputPath = getMojmapTinyPath(version);
    ensureDir(dirname(outputPath));

    const mappingIO = getMappingIO();
    await mappingIO.convert(proguardPath, intermediaryPath, outputPath, {
      onProgress: (msg) => logger.debug(`MappingIO: ${msg}`),
    });

    // Step 4: Validate output format
    const parsed = parseTinyV2(outputPath);
    if (
      !parsed.header.namespaces.includes('intermediary') ||
      !parsed.header.namespaces.includes('named')
    ) {
      throw new Error(
        `Invalid mapping-io output: expected namespaces 'intermediary' and 'named', got ${parsed.header.namespaces.join(', ')}`,
      );
    }

    logger.info(`Mojmap converted and saved to ${outputPath}`);

    // Cache the converted mapping
    this.cache.cacheMapping(version, 'mojmap', outputPath);

    return outputPath;
  }

  /**
   * Check if mappings are available
   */
  hasMappings(version: string, mappingType: MappingType): boolean {
    return this.cache.hasMappings(version, mappingType);
  }

  /**
   * Verify mappings exist for a version
   */
  async verifyMappingsAvailable(version: string, mappingType: MappingType): Promise<void> {
    // For Yarn, check Maven
    if (mappingType === 'yarn') {
      const exists = await this.fabricMaven.yarnMappingsExist(version);
      if (!exists) {
        throw new MappingNotFoundError(version, mappingType);
      }
    }
    // Mojmap should always exist for 1.21.1+
    // Intermediary should exist for all Fabric-supported versions
  }

  /**
   * Throw a clear error if the version is unobfuscated and no mapping files exist.
   * Called just before attempting a download, AFTER cache checks, so that cached
   * mappings still work without hitting the network.
   */
  private async throwIfUnobfuscated(version: string, mappingType: MappingType): Promise<void> {
    const isUnobfuscated = await this.versionManager.isVersionUnobfuscated(version);
    if (!isUnobfuscated) return;

    if (mappingType === 'mojmap') {
      throw new MappingNotFoundError(
        version,
        mappingType,
        `Mojmap mapping files are not available for unobfuscated version ${version}. The JAR is already in Mojang's human-readable names — decompile it directly with mapping 'mojmap'.`,
      );
    }
    throw new MappingNotFoundError(
      version,
      mappingType,
      `${mappingType} mappings are not available for unobfuscated version ${version}. Use 'mojmap' mapping instead — the JAR ships without obfuscation.`,
    );
  }

  /**
   * Lookup result type
   */
  private createLookupResult(
    found: boolean,
    source: string,
    target?: string,
    type?: 'class' | 'method' | 'field',
    className?: string,
  ): MappingLookupResult {
    return { found, source, target, type, className };
  }

  /**
   * Lookup a symbol mapping between namespaces
   * Searches for class, method, or field names and returns the translation
   *
   * Mapping files and their namespaces:
   * - intermediary file: 'official' (obfuscated) ↔ 'intermediary'
   * - yarn file: 'intermediary' ↔ 'named' (yarn names)
   * - mojmap file: 'intermediary' ↔ 'named' (mojang names)
   *
   * The routing graph (intermediary is the central hub):
   *   official ←──→ intermediary ←──→ yarn
   *                      ↕
   *                   mojmap
   */
  async lookupMapping(
    version: string,
    symbol: string,
    sourceMapping: MappingType,
    targetMapping: MappingType,
  ): Promise<MappingLookupResult> {
    logger.info(`Looking up mapping: ${symbol} (${sourceMapping} -> ${targetMapping})`);

    // Same mapping type - no translation needed
    if (sourceMapping === targetMapping) {
      return this.createLookupResult(true, symbol, symbol);
    }

    // Determine routing strategy
    const singleFile = this.getSingleFileLookup(sourceMapping, targetMapping);

    if (singleFile) {
      // Direct lookup in a single file
      return this.lookupInSingleFile(version, symbol, sourceMapping, targetMapping, singleFile);
    }

    // Two-step lookup via intermediary bridge
    return this.lookupViaBridge(version, symbol, sourceMapping, targetMapping);
  }

  /**
   * Determine if two mapping types can be looked up in a single file
   * Returns the file type to use, or null if two-step lookup is required
   */
  private getSingleFileLookup(
    source: MappingType,
    target: MappingType,
  ): 'intermediary' | 'yarn' | 'mojmap' | null {
    // official ↔ intermediary: use intermediary file
    if (
      (source === 'official' && target === 'intermediary') ||
      (source === 'intermediary' && target === 'official')
    ) {
      return 'intermediary';
    }

    // intermediary ↔ yarn: use yarn file
    if (
      (source === 'intermediary' && target === 'yarn') ||
      (source === 'yarn' && target === 'intermediary')
    ) {
      return 'yarn';
    }

    // intermediary ↔ mojmap: use mojmap file
    if (
      (source === 'intermediary' && target === 'mojmap') ||
      (source === 'mojmap' && target === 'intermediary')
    ) {
      return 'mojmap';
    }

    // Cross-file lookup required (official↔yarn, official↔mojmap, yarn↔mojmap)
    return null;
  }

  /**
   * Get the namespace name for a mapping type within a specific file
   * Note: fileType is kept for potential future validation but not currently used
   */
  private getNamespaceForType(
    mappingType: MappingType,
    _fileType: 'intermediary' | 'yarn' | 'mojmap',
  ): string {
    // Intermediary file has: official, intermediary
    // Yarn file has: intermediary, named
    // Mojmap file has: intermediary, named

    if (mappingType === 'official') {
      return 'official';
    }

    if (mappingType === 'intermediary') {
      return 'intermediary';
    }

    // Both yarn and mojmap use 'named' namespace in their respective files
    if (mappingType === 'yarn' || mappingType === 'mojmap') {
      return 'named';
    }

    return 'intermediary'; // fallback
  }

  /**
   * Perform lookup in a single mapping file
   */
  private async lookupInSingleFile(
    version: string,
    symbol: string,
    sourceMapping: MappingType,
    targetMapping: MappingType,
    fileType: 'intermediary' | 'yarn' | 'mojmap',
  ): Promise<MappingLookupResult> {
    const mappingPath = await this.getMappings(version, fileType);
    const mappingData = parseTinyV2(mappingPath);

    const sourceNamespace = this.getNamespaceForType(sourceMapping, fileType);
    const targetNamespace = this.getNamespaceForType(targetMapping, fileType);

    const sourceIndex = mappingData.header.namespaces.indexOf(sourceNamespace);
    const targetIndex = mappingData.header.namespaces.indexOf(targetNamespace);

    if (sourceIndex === -1 || targetIndex === -1) {
      logger.warn(
        `Namespace not found in ${fileType} file: source=${sourceNamespace}(${sourceIndex}), target=${targetNamespace}(${targetIndex}). Available: ${mappingData.header.namespaces.join(', ')}`,
      );
      return this.createLookupResult(false, symbol);
    }

    return this.searchInMappingData(mappingData, symbol, sourceIndex, targetIndex);
  }

  /**
   * Perform two-step lookup via intermediary bridge
   *
   * Routes:
   * - official → yarn: official→intermediary (int file), intermediary→yarn (yarn file)
   * - official → mojmap: official→intermediary (int file), intermediary→mojmap (mojmap file)
   * - yarn → official: yarn→intermediary (yarn file), intermediary→official (int file)
   * - mojmap → official: mojmap→intermediary (mojmap file), intermediary→official (int file)
   * - yarn → mojmap: yarn→intermediary (yarn file), intermediary→mojmap (mojmap file)
   * - mojmap → yarn: mojmap→intermediary (mojmap file), intermediary→yarn (yarn file)
   */
  private async lookupViaBridge(
    version: string,
    symbol: string,
    sourceMapping: MappingType,
    targetMapping: MappingType,
  ): Promise<MappingLookupResult> {
    logger.info(`Two-step lookup: ${sourceMapping} → intermediary → ${targetMapping}`);

    // Step 1: Source → Intermediary
    const step1File = this.getFileForMapping(sourceMapping);
    const step1Path = await this.getMappings(version, step1File);
    const step1Data = parseTinyV2(step1Path);

    const sourceNamespace = this.getNamespaceForType(sourceMapping, step1File);
    const intermediaryNamespace = 'intermediary';

    const sourceIndex = step1Data.header.namespaces.indexOf(sourceNamespace);
    const intermediaryIndex = step1Data.header.namespaces.indexOf(intermediaryNamespace);

    if (sourceIndex === -1 || intermediaryIndex === -1) {
      logger.warn(
        `Step 1 namespace not found: source=${sourceNamespace}(${sourceIndex}), intermediary(${intermediaryIndex})`,
      );
      return this.createLookupResult(false, symbol);
    }

    // Find intermediary name for the symbol
    const step1Result = this.searchInMappingData(step1Data, symbol, sourceIndex, intermediaryIndex);

    if (!step1Result.found || !step1Result.target) {
      return this.createLookupResult(false, symbol);
    }

    const intermediarySymbol = step1Result.target;
    const symbolType = step1Result.type;
    const step1ClassName = step1Result.className;

    // Step 2: Intermediary → Target
    const step2File = this.getFileForMapping(targetMapping);
    const step2Path = await this.getMappings(version, step2File);
    const step2Data = parseTinyV2(step2Path);

    const targetNamespace = this.getNamespaceForType(targetMapping, step2File);
    const step2IntermediaryIndex = step2Data.header.namespaces.indexOf(intermediaryNamespace);
    const targetIndex = step2Data.header.namespaces.indexOf(targetNamespace);

    if (step2IntermediaryIndex === -1 || targetIndex === -1) {
      logger.warn(
        `Step 2 namespace not found: intermediary(${step2IntermediaryIndex}), target=${targetNamespace}(${targetIndex})`,
      );
      return this.createLookupResult(false, symbol);
    }

    // For methods/fields, we need to find them within the correct class context
    if (symbolType === 'method' || symbolType === 'field') {
      // First, translate the class name to intermediary if needed
      let intermediaryClassName = step1ClassName;
      if (step1ClassName && sourceIndex !== intermediaryIndex) {
        // The className from step1 is in source namespace, we have intermediary from the lookup
        // Actually, step1Result.className is already in source namespace, and we got intermediary
        // We need to find the class in step2 using intermediary className
        // Let's search for the class first to get its intermediary name
        for (const cls of step1Data.classes) {
          if (cls.names[sourceIndex] === step1ClassName) {
            intermediaryClassName = cls.names[intermediaryIndex];
            break;
          }
        }
      }

      // Now search for the method/field in step2 data within the correct class
      const step2Result = this.searchMemberInClass(
        step2Data,
        intermediarySymbol,
        intermediaryClassName,
        symbolType,
        step2IntermediaryIndex,
        targetIndex,
      );

      if (step2Result.found) {
        return this.createLookupResult(
          true,
          symbol,
          step2Result.target,
          symbolType,
          step2Result.className,
        );
      }

      return this.createLookupResult(false, symbol);
    }

    // For classes, do a simple lookup
    const step2Result = this.searchInMappingData(
      step2Data,
      intermediarySymbol,
      step2IntermediaryIndex,
      targetIndex,
    );

    if (step2Result.found) {
      return this.createLookupResult(true, symbol, step2Result.target, step2Result.type);
    }

    return this.createLookupResult(false, symbol);
  }

  /**
   * Get the file type that contains a mapping type
   */
  private getFileForMapping(mappingType: MappingType): 'intermediary' | 'yarn' | 'mojmap' {
    switch (mappingType) {
      case 'official':
        return 'intermediary';
      case 'intermediary':
        return 'intermediary'; // Could use any file, but intermediary has official too
      case 'yarn':
        return 'yarn';
      case 'mojmap':
        return 'mojmap';
    }
  }

  /**
   * Search for a symbol in parsed mapping data
   */
  private searchInMappingData(
    mappingData: ReturnType<typeof parseTinyV2>,
    symbol: string,
    sourceIndex: number,
    targetIndex: number,
  ): MappingLookupResult {
    // Normalize symbol for comparison (handle both / and . separators)
    const normalizedSymbol = symbol.replace(/\./g, '/');

    for (const cls of mappingData.classes) {
      const sourceName = cls.names[sourceIndex];
      const targetName = cls.names[targetIndex];

      // Check class name match (support simple name or full path)
      if (
        sourceName === symbol ||
        sourceName === normalizedSymbol ||
        sourceName.endsWith(`/${symbol}`) ||
        sourceName.replace(/\//g, '.') === symbol
      ) {
        return this.createLookupResult(true, sourceName, targetName, 'class');
      }

      // Check method names
      for (const method of cls.methods) {
        const sourceMethodName = method.names[sourceIndex];
        if (sourceMethodName === symbol) {
          const targetMethodName = method.names[targetIndex];
          return this.createLookupResult(
            true,
            sourceMethodName,
            targetMethodName,
            'method',
            sourceName,
          );
        }
      }

      // Check field names
      for (const field of cls.fields) {
        const sourceFieldName = field.names[sourceIndex];
        if (sourceFieldName === symbol) {
          const targetFieldName = field.names[targetIndex];
          return this.createLookupResult(
            true,
            sourceFieldName,
            targetFieldName,
            'field',
            sourceName,
          );
        }
      }
    }

    return this.createLookupResult(false, symbol);
  }

  /**
   * Search for a method or field within a specific class context
   */
  private searchMemberInClass(
    mappingData: ReturnType<typeof parseTinyV2>,
    memberName: string,
    className: string | undefined,
    memberType: 'method' | 'field',
    sourceIndex: number,
    targetIndex: number,
  ): MappingLookupResult {
    for (const cls of mappingData.classes) {
      const classSourceName = cls.names[sourceIndex];
      const classTargetName = cls.names[targetIndex];

      // If className is specified, only search in that class
      if (className && classSourceName !== className) {
        continue;
      }

      if (memberType === 'method') {
        for (const method of cls.methods) {
          const sourceMethodName = method.names[sourceIndex];
          if (sourceMethodName === memberName) {
            const targetMethodName = method.names[targetIndex];
            return this.createLookupResult(
              true,
              sourceMethodName,
              targetMethodName,
              'method',
              classTargetName,
            );
          }
        }
      } else {
        for (const field of cls.fields) {
          const sourceFieldName = field.names[sourceIndex];
          if (sourceFieldName === memberName) {
            const targetFieldName = field.names[targetIndex];
            return this.createLookupResult(
              true,
              sourceFieldName,
              targetFieldName,
              'field',
              classTargetName,
            );
          }
        }
      }
    }

    return this.createLookupResult(false, memberName);
  }
}

/**
 * Result type for mapping lookups
 */
export interface MappingLookupResult {
  found: boolean;
  type?: 'class' | 'method' | 'field';
  source: string;
  target?: string;
  className?: string;
}

// Singleton instance
let mappingServiceInstance: MappingService | undefined;

export function getMappingService(): MappingService {
  if (!mappingServiceInstance) {
    mappingServiceInstance = new MappingService();
  }
  return mappingServiceInstance;
}
