import axios, { AxiosError } from 'axios';
import { logger } from '../utils/logger';

/**
 * Configuration constants for community node fetching
 */
const FETCH_CONFIG = {
  /** Default timeout for Strapi API requests (ms) */
  STRAPI_TIMEOUT: 30000,
  /** Default timeout for npm registry requests (ms) */
  NPM_REGISTRY_TIMEOUT: 15000,
  /** Default timeout for npm downloads API (ms) */
  NPM_DOWNLOADS_TIMEOUT: 10000,
  /** Base delay between retries (ms) */
  RETRY_DELAY: 1000,
  /** Maximum number of retry attempts */
  MAX_RETRIES: 3,
  /** Default delay between requests for rate limiting (ms) */
  RATE_LIMIT_DELAY: 300,
  /** Default delay after hitting 429 (ms) */
  RATE_LIMIT_429_DELAY: 60000,
} as const;

/**
 * Strapi API response types for verified community nodes
 */
export interface StrapiCommunityNodeAttributes {
  name: string;
  displayName: string;
  description: string;
  packageName: string;
  authorName: string;
  authorGithubUrl?: string;
  npmVersion: string;
  numberOfDownloads: number;
  numberOfStars: number;
  isOfficialNode: boolean;
  isPublished: boolean;
  nodeDescription: any; // Complete n8n node schema
  nodeVersions?: any[];
  checksum?: string;
  createdAt: string;
  updatedAt: string;
}

export interface StrapiCommunityNode {
  id: number;
  attributes: StrapiCommunityNodeAttributes;
}

export interface StrapiPaginatedResponse<T> {
  data: Array<{ id: number; attributes: T }>;
  meta: {
    pagination: {
      page: number;
      pageSize: number;
      pageCount: number;
      total: number;
    };
  };
}

/**
 * npm registry search response types
 */
export interface NpmPackageInfo {
  name: string;
  version: string;
  description: string;
  keywords: string[];
  date: string;
  links: {
    npm: string;
    homepage?: string;
    repository?: string;
  };
  author?: {
    name?: string;
    email?: string;
    username?: string;
  };
  publisher?: {
    username: string;
    email: string;
  };
  maintainers: Array<{ username: string; email: string }>;
}

export interface NpmSearchResult {
  package: NpmPackageInfo;
  score: {
    final: number;
    detail: {
      quality: number;
      popularity: number;
      maintenance: number;
    };
  };
  searchScore: number;
}

export interface NpmSearchResponse {
  objects: NpmSearchResult[];
  total: number;
  time: string;
}

/**
 * Response type for full package data including README
 */
export interface NpmPackageWithReadme {
  name: string;
  version: string;
  description?: string;
  readme?: string;
  readmeFilename?: string;
  homepage?: string;
  repository?: {
    type?: string;
    url?: string;
  };
  keywords?: string[];
  license?: string;
  'dist-tags'?: {
    latest?: string;
  };
}

/**
 * Fetches community nodes from n8n Strapi API and npm registry.
 * Follows the pattern from template-fetcher.ts.
 */
export class CommunityNodeFetcher {
  private readonly strapiBaseUrl: string;
  private readonly npmSearchUrl = 'https://registry.npmjs.org/-/v1/search';
  private readonly npmRegistryUrl = 'https://registry.npmjs.org';
  private readonly maxRetries = FETCH_CONFIG.MAX_RETRIES;
  private readonly retryDelay = FETCH_CONFIG.RETRY_DELAY;
  private readonly strapiPageSize = 25;
  private readonly npmPageSize = 250; // npm API max

  /** Regex for validating npm package names per npm naming rules */
  private readonly npmPackageNameRegex = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;

  constructor(environment: 'production' | 'staging' = 'production') {
    this.strapiBaseUrl =
      environment === 'production'
        ? 'https://api.n8n.io/api/community-nodes'
        : 'https://api-staging.n8n.io/api/community-nodes';
  }

  /**
   * Validates npm package name to prevent path traversal and injection attacks.
   * @see https://github.com/npm/validate-npm-package-name
   */
  private validatePackageName(packageName: string): boolean {
    if (!packageName || typeof packageName !== 'string') {
      return false;
    }
    // Max length per npm spec
    if (packageName.length > 214) {
      return false;
    }
    // Must match npm naming pattern
    if (!this.npmPackageNameRegex.test(packageName)) {
      return false;
    }
    // Block path traversal attempts
    if (packageName.includes('..') || packageName.includes('//')) {
      return false;
    }
    return true;
  }

  /**
   * Checks if an error is a rate limit (429) response
   */
  private isRateLimitError(error: unknown): boolean {
    return axios.isAxiosError(error) && error.response?.status === 429;
  }

  /**
   * Retry helper for API calls (same pattern as TemplateFetcher)
   * Handles 429 rate limit responses with extended delay
   */
  private async retryWithBackoff<T>(
    fn: () => Promise<T>,
    context: string,
    maxRetries: number = this.maxRetries
  ): Promise<T | null> {
    let lastError: unknown;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await fn();
      } catch (error: unknown) {
        lastError = error;

        if (attempt < maxRetries) {
          // Handle 429 rate limit with longer delay
          if (this.isRateLimitError(error)) {
            const delay = FETCH_CONFIG.RATE_LIMIT_429_DELAY;
            logger.warn(
              `${context} - Rate limited (429), waiting ${delay / 1000}s before retry...`
            );
            await this.sleep(delay);
          } else {
            const delay = this.retryDelay * attempt; // Exponential backoff
            logger.warn(
              `${context} - Attempt ${attempt}/${maxRetries} failed, retrying in ${delay}ms...`
            );
            await this.sleep(delay);
          }
        }
      }
    }

    logger.error(`${context} - All ${maxRetries} attempts failed, skipping`, lastError);
    return null;
  }

  /**
   * Fetch all verified community nodes from n8n Strapi API.
   * These nodes include full nodeDescription schemas - no parsing needed!
   */
  async fetchVerifiedNodes(
    progressCallback?: (message: string, current: number, total: number) => void
  ): Promise<StrapiCommunityNode[]> {
    const allNodes: StrapiCommunityNode[] = [];
    let page = 1;
    let hasMore = true;
    let total = 0;

    logger.info('Fetching verified community nodes from n8n Strapi API...');

    while (hasMore) {
      const result = await this.retryWithBackoff(
        async () => {
          const response = await axios.get<StrapiPaginatedResponse<StrapiCommunityNodeAttributes>>(
            this.strapiBaseUrl,
            {
              params: {
                'pagination[page]': page,
                'pagination[pageSize]': this.strapiPageSize,
              },
              timeout: FETCH_CONFIG.STRAPI_TIMEOUT,
            }
          );
          return response.data;
        },
        `Fetching verified nodes page ${page}`
      );

      if (result === null) {
        logger.warn(`Skipping page ${page} after failed attempts`);
        page++;
        continue;
      }

      const nodes = result.data.map((item) => ({
        id: item.id,
        attributes: item.attributes,
      }));

      allNodes.push(...nodes);
      total = result.meta.pagination.total;

      if (progressCallback) {
        progressCallback(`Fetching verified nodes`, allNodes.length, total);
      }

      logger.debug(
        `Fetched page ${page}/${result.meta.pagination.pageCount}: ${nodes.length} nodes (total: ${allNodes.length}/${total})`
      );

      // Check if there are more pages
      if (page >= result.meta.pagination.pageCount) {
        hasMore = false;
      }

      page++;

      // Rate limiting
      if (hasMore) {
        await this.sleep(FETCH_CONFIG.RATE_LIMIT_DELAY);
      }
    }

    logger.info(`Fetched ${allNodes.length} verified community nodes from Strapi API`);
    return allNodes;
  }

  /**
   * Fetch popular community node packages from npm registry.
   * Sorted by popularity (downloads). Returns package metadata only.
   * To get node schemas, packages need to be downloaded and parsed.
   *
   * @param limit Maximum number of packages to fetch (default: 100)
   */
  async fetchNpmPackages(
    limit: number = 100,
    progressCallback?: (message: string, current: number, total: number) => void
  ): Promise<NpmSearchResult[]> {
    const allPackages: NpmSearchResult[] = [];
    let offset = 0;
    const targetLimit = Math.min(limit, 1000); // npm API practical limit

    logger.info(`Fetching top ${targetLimit} community node packages from npm registry...`);

    while (allPackages.length < targetLimit) {
      const remaining = targetLimit - allPackages.length;
      const size = Math.min(this.npmPageSize, remaining);

      const result = await this.retryWithBackoff(
        async () => {
          const response = await axios.get<NpmSearchResponse>(this.npmSearchUrl, {
            params: {
              text: 'keywords:n8n-community-node-package',
              size,
              from: offset,
              // Sort by popularity (downloads)
              quality: 0,
              popularity: 1,
              maintenance: 0,
            },
            timeout: FETCH_CONFIG.STRAPI_TIMEOUT,
          });
          return response.data;
        },
        `Fetching npm packages (offset ${offset})`
      );

      if (result === null) {
        logger.warn(`Skipping npm fetch at offset ${offset} after failed attempts`);
        break;
      }

      if (result.objects.length === 0) {
        break; // No more packages
      }

      allPackages.push(...result.objects);

      if (progressCallback) {
        progressCallback(`Fetching npm packages`, allPackages.length, Math.min(result.total, targetLimit));
      }

      logger.debug(
        `Fetched ${result.objects.length} packages (total: ${allPackages.length}/${Math.min(result.total, targetLimit)})`
      );

      offset += size;

      // Rate limiting
      await this.sleep(FETCH_CONFIG.RATE_LIMIT_DELAY);
    }

    // Sort by popularity score (highest first)
    allPackages.sort((a, b) => b.score.detail.popularity - a.score.detail.popularity);

    logger.info(`Fetched ${allPackages.length} community node packages from npm`);
    return allPackages.slice(0, limit);
  }

  /**
   * Fetch package.json for a specific npm package to get the n8n node configuration.
   * Validates package name to prevent path traversal attacks.
   */
  async fetchPackageJson(packageName: string, version?: string): Promise<any | null> {
    // Validate package name to prevent path traversal
    if (!this.validatePackageName(packageName)) {
      logger.warn(`Invalid package name rejected: ${packageName}`);
      return null;
    }

    const url = version
      ? `${this.npmRegistryUrl}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`
      : `${this.npmRegistryUrl}/${encodeURIComponent(packageName)}/latest`;

    return this.retryWithBackoff(
      async () => {
        const response = await axios.get(url, { timeout: FETCH_CONFIG.NPM_REGISTRY_TIMEOUT });
        return response.data;
      },
      `Fetching package.json for ${packageName}${version ? `@${version}` : ''}`
    );
  }

  /**
   * Download package tarball URL for a specific package version.
   * Returns the tarball URL that can be used to download and extract the package.
   */
  async getPackageTarballUrl(packageName: string, version?: string): Promise<string | null> {
    const packageJson = await this.fetchPackageJson(packageName, version);

    if (!packageJson) {
      return null;
    }

    // For specific version fetch, dist.tarball is directly available
    if (packageJson.dist?.tarball) {
      return packageJson.dist.tarball;
    }

    // For full package fetch, get the latest version's tarball
    const latestVersion = packageJson['dist-tags']?.latest;
    if (latestVersion && packageJson.versions?.[latestVersion]?.dist?.tarball) {
      return packageJson.versions[latestVersion].dist.tarball;
    }

    return null;
  }

  /**
   * Fetch full package data including README from npm registry.
   * Uses the base package URL (not /latest) to get the README field.
   * Validates package name to prevent path traversal attacks.
   *
   * @param packageName npm package name (e.g., "n8n-nodes-brightdata")
   * @returns Full package data including readme, or null if fetch failed
   */
  async fetchPackageWithReadme(packageName: string): Promise<NpmPackageWithReadme | null> {
    // Validate package name to prevent path traversal
    if (!this.validatePackageName(packageName)) {
      logger.warn(`Invalid package name rejected for README fetch: ${packageName}`);
      return null;
    }

    const url = `${this.npmRegistryUrl}/${encodeURIComponent(packageName)}`;

    return this.retryWithBackoff(
      async () => {
        const response = await axios.get<NpmPackageWithReadme>(url, {
          timeout: FETCH_CONFIG.NPM_REGISTRY_TIMEOUT,
        });
        return response.data;
      },
      `Fetching package with README for ${packageName}`
    );
  }

  /**
   * Fetch READMEs for multiple packages in batch with rate limiting.
   * Returns a Map of packageName -> readme content.
   *
   * @param packageNames Array of npm package names
   * @param progressCallback Optional callback for progress updates
   * @param concurrency Number of concurrent requests (default: 1 for rate limiting)
   * @returns Map of packageName to README content (null if not found)
   */
  async fetchReadmesBatch(
    packageNames: string[],
    progressCallback?: (message: string, current: number, total: number) => void,
    concurrency: number = 1
  ): Promise<Map<string, string | null>> {
    const results = new Map<string, string | null>();
    const total = packageNames.length;

    logger.info(`Fetching READMEs for ${total} packages (concurrency: ${concurrency})...`);

    // Process in batches based on concurrency
    for (let i = 0; i < packageNames.length; i += concurrency) {
      const batch = packageNames.slice(i, i + concurrency);

      // Process batch concurrently
      const batchPromises = batch.map(async (packageName) => {
        const data = await this.fetchPackageWithReadme(packageName);
        return { packageName, readme: data?.readme || null };
      });

      const batchResults = await Promise.all(batchPromises);

      for (const { packageName, readme } of batchResults) {
        results.set(packageName, readme);
      }

      if (progressCallback) {
        progressCallback('Fetching READMEs', Math.min(i + concurrency, total), total);
      }

      // Rate limiting between batches
      if (i + concurrency < packageNames.length) {
        await this.sleep(FETCH_CONFIG.RATE_LIMIT_DELAY);
      }
    }

    const foundCount = Array.from(results.values()).filter((v) => v !== null).length;
    logger.info(`Fetched ${foundCount}/${total} READMEs successfully`);

    return results;
  }

  /**
   * Get download statistics for a package from npm.
   * Validates package name to prevent path traversal attacks.
   */
  async getPackageDownloads(
    packageName: string,
    period: 'last-week' | 'last-month' = 'last-week'
  ): Promise<number | null> {
    // Validate package name to prevent path traversal
    if (!this.validatePackageName(packageName)) {
      logger.warn(`Invalid package name rejected for downloads: ${packageName}`);
      return null;
    }

    return this.retryWithBackoff(
      async () => {
        const response = await axios.get(
          `https://api.npmjs.org/downloads/point/${period}/${encodeURIComponent(packageName)}`,
          { timeout: FETCH_CONFIG.NPM_DOWNLOADS_TIMEOUT }
        );
        return response.data.downloads;
      },
      `Fetching downloads for ${packageName}`
    );
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}
