import { join, posix } from "node:path";

import { Semaphore } from "es-toolkit/promise";

import {
  FETCH_CONCURRENCY_LIMIT,
  MAX_FILE_SIZE,
  RULESYNC_AIIGNORE_FILE_NAME,
  RULESYNC_HOOKS_FILE_NAME,
  RULESYNC_MCP_FILE_NAME,
  RULESYNC_PERMISSIONS_FILE_NAME,
  RULESYNC_RELATIVE_DIR_PATH,
} from "../constants/rulesync-paths.js";
import { CommandsProcessor } from "../features/commands/commands-processor.js";
import { HooksProcessor } from "../features/hooks/hooks-processor.js";
import { IgnoreProcessor } from "../features/ignore/ignore-processor.js";
import { McpProcessor } from "../features/mcp/mcp-processor.js";
import { RulesProcessor } from "../features/rules/rules-processor.js";
import { SkillsProcessor } from "../features/skills/skills-processor.js";
import { SubagentsProcessor } from "../features/subagents/subagents-processor.js";
import type { Feature } from "../types/features.js";
import { ALL_FEATURES } from "../types/features.js";
import type { FetchTarget } from "../types/fetch-targets.js";
import type {
  ConflictStrategy,
  FetchFileResult,
  FetchOptions,
  FetchSummary,
  GitHubFileEntry,
  ParsedSource,
} from "../types/fetch.js";
import type { ToolTarget } from "../types/tool-targets.js";
import {
  checkPathTraversal,
  createTempDirectory,
  fileExists,
  removeTempDirectory,
  toPosixPath,
  writeFileContent,
} from "../utils/file.js";
import type { Logger } from "../utils/logger.js";
import { GitHubClient, GitHubClientError } from "./github-client.js";
import { listDirectoryRecursive, withSemaphore } from "./github-utils.js";
import { parseSource } from "./source-parser.js";

/**
 * Feature to path mapping for filtering (rulesync format)
 */
const FEATURE_PATHS: Record<Feature, string[]> = {
  rules: ["rules"],
  commands: ["commands"],
  subagents: ["subagents"],
  skills: ["skills"],
  ignore: [RULESYNC_AIIGNORE_FILE_NAME],
  mcp: [RULESYNC_MCP_FILE_NAME],
  hooks: [RULESYNC_HOOKS_FILE_NAME],
  permissions: [RULESYNC_PERMISSIONS_FILE_NAME],
};

/**
 * Check if target is a tool target (not rulesync)
 */
function isToolTarget(target: FetchTarget): target is ToolTarget {
  return target !== "rulesync";
}

/**
 * Validate file size against maximum limit
 * @throws {GitHubClientError} If file size exceeds limit
 */
function validateFileSize(relativePath: string, size: number): void {
  if (size > MAX_FILE_SIZE) {
    throw new GitHubClientError(
      `File "${relativePath}" exceeds maximum size limit (${(size / 1024 / 1024).toFixed(2)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
    );
  }
}

/**
 * Result of feature conversion
 */
type FeatureConversionResult = {
  converted: number;
  convertedPaths: string[];
};

/**
 * Processor type for feature conversion
 */
type FeatureProcessor = {
  loadToolFiles(): Promise<unknown[]>;
  convertToolFilesToRulesyncFiles(
    toolFiles: unknown[],
  ): Promise<
    Array<{ getRelativeDirPath(): string; getRelativeFilePath(): string; getFileContent(): string }>
  >;
};

/**
 * Process feature conversion for a single feature type
 * @param processor - The processor to use for loading and converting files
 * @param outputDir - Output directory for converted files
 * @returns The paths of converted files
 */
async function processFeatureConversion(params: {
  processor: FeatureProcessor;
  outputDir: string;
}): Promise<{ paths: string[] }> {
  const { processor, outputDir } = params;
  const paths: string[] = [];

  const toolFiles = await processor.loadToolFiles();
  if (toolFiles.length === 0) {
    return { paths: [] };
  }

  const rulesyncFiles = await processor.convertToolFilesToRulesyncFiles(toolFiles);
  for (const file of rulesyncFiles) {
    const relativePath = join(file.getRelativeDirPath(), file.getRelativeFilePath());
    const outputPath = join(outputDir, relativePath);
    await writeFileContent(outputPath, file.getFileContent());
    paths.push(relativePath);
  }

  return { paths };
}

/**
 * Convert fetched tool-specific files to rulesync format
 * @param tempDir - Temporary directory containing tool-specific files
 * @param outputDir - Output directory for rulesync files
 * @param target - Tool target to convert from
 * @param features - Features to convert
 * @returns Number of converted files and their paths
 */
async function convertFetchedFilesToRulesync(params: {
  tempDir: string;
  outputDir: string;
  target: ToolTarget;
  features: Feature[];
  logger: Logger;
}): Promise<FeatureConversionResult> {
  const { tempDir, outputDir, target, features, logger } = params;
  const convertedPaths: string[] = [];

  // Feature conversion configurations
  // Each config defines how to get supported targets and create a processor
  const featureConfigs: Array<{
    feature: Feature;
    getTargets: () => ToolTarget[];
    createProcessor: () => FeatureProcessor;
  }> = [
    {
      feature: "rules",
      getTargets: () => RulesProcessor.getToolTargets({ global: false }),
      createProcessor: () =>
        new RulesProcessor({ outputRoot: tempDir, toolTarget: target, global: false, logger }),
    },
    {
      feature: "commands",
      getTargets: () =>
        CommandsProcessor.getToolTargets({ global: false, includeSimulated: false }),
      createProcessor: () =>
        new CommandsProcessor({ outputRoot: tempDir, toolTarget: target, global: false, logger }),
    },
    {
      feature: "subagents",
      getTargets: () =>
        SubagentsProcessor.getToolTargets({ global: false, includeSimulated: false }),
      createProcessor: () =>
        new SubagentsProcessor({ outputRoot: tempDir, toolTarget: target, global: false, logger }),
    },
    {
      feature: "ignore",
      getTargets: () => IgnoreProcessor.getToolTargets(),
      createProcessor: () =>
        new IgnoreProcessor({ outputRoot: tempDir, toolTarget: target, logger }),
    },
    {
      feature: "mcp",
      getTargets: () => McpProcessor.getToolTargets({ global: false }),
      createProcessor: () =>
        new McpProcessor({ outputRoot: tempDir, toolTarget: target, global: false, logger }),
    },
    {
      feature: "hooks",
      getTargets: () => HooksProcessor.getToolTargets({ global: false }),
      createProcessor: () =>
        new HooksProcessor({ outputRoot: tempDir, toolTarget: target, global: false, logger }),
    },
  ];

  // Process each feature using data-driven approach
  for (const config of featureConfigs) {
    if (!features.includes(config.feature)) {
      continue;
    }
    const supportedTargets = config.getTargets();
    if (!supportedTargets.includes(target)) {
      continue;
    }
    const processor = config.createProcessor();
    const result = await processFeatureConversion({ processor, outputDir });
    convertedPaths.push(...result.paths);
  }

  // Skills conversion is not yet supported in fetch command
  // Note: Skills are more complex as they are directory-based.
  // Users can use the import command for skills conversion.
  if (features.includes("skills")) {
    logger.debug(
      "Skills conversion is not yet supported in fetch command. Use import command instead.",
    );
  }

  return { converted: convertedPaths.length, convertedPaths };
}

/**
 * Resolve features from options, handling wildcard
 */
function resolveFeatures(features?: string[]): Feature[] {
  if (!features || features.length === 0 || features.includes("*")) {
    return [...ALL_FEATURES];
  }
  // eslint-disable-next-line no-type-assertion/no-type-assertion
  return features.filter((f): f is Feature => ALL_FEATURES.includes(f as Feature));
}

/**
 * Type guard for error objects with statusCode
 */
function hasStatusCode(error: unknown): error is { statusCode: number } {
  if (typeof error !== "object" || error === null || !("statusCode" in error)) {
    return false;
  }
  const maybeStatus = Object.getOwnPropertyDescriptor(error, "statusCode")?.value;
  return typeof maybeStatus === "number";
}

/**
 * Check if error is a 404 "not found" error
 */
function isNotFoundError(error: unknown): boolean {
  if (error instanceof GitHubClientError && error.statusCode === 404) {
    return true;
  }
  // Also handle plain objects with statusCode property (for test mocks)
  if (hasStatusCode(error) && error.statusCode === 404) {
    return true;
  }
  return false;
}

/**
 * Parameters for fetch operation
 */
export type FetchParams = {
  source: string;
  options?: FetchOptions;
  outputRoot?: string;
  logger: Logger;
};

/**
 * Fetch files from a Git repository
 * Searches for feature directories (rules/, commands/, skills/, etc.) directly at the specified path
 *
 * When target is "rulesync" (default), files are fetched as-is.
 * When target is a tool target (e.g., "claudecode"), files are fetched to a temp directory,
 * converted to rulesync format, and written to the output directory.
 */
export async function fetchFiles(params: FetchParams): Promise<FetchSummary> {
  const { source, options = {}, outputRoot = process.cwd(), logger } = params;

  // Parse source
  const parsed = parseSource(source);

  // Check if provider is supported
  if (parsed.provider === "gitlab") {
    throw new Error(
      "GitLab is not yet supported. Currently only GitHub repositories are supported.",
    );
  }

  // Resolve options
  const resolvedRef = options.ref ?? parsed.ref;
  // Normalize backslashes to forward slashes for GitHub API compatibility.
  const resolvedPath = toPosixPath(options.path ?? parsed.path ?? ".");
  const outputDir = options.output ?? RULESYNC_RELATIVE_DIR_PATH;
  const conflictStrategy: ConflictStrategy = options.conflict ?? "overwrite";
  const enabledFeatures = resolveFeatures(options.features);
  const target: FetchTarget = options.target ?? "rulesync";

  // Validate output directory to prevent path traversal attacks
  checkPathTraversal({
    relativePath: outputDir,
    intendedRootDir: outputRoot,
  });

  // Initialize GitHub client
  const token = GitHubClient.resolveToken(options.token);
  const client = new GitHubClient({ token });

  // Validate repository
  logger.debug(`Validating repository: ${parsed.owner}/${parsed.repo}`);
  const isValid = await client.validateRepository(parsed.owner, parsed.repo);
  if (!isValid) {
    throw new GitHubClientError(
      `Repository not found: ${parsed.owner}/${parsed.repo}. Check the repository name and your access permissions.`,
      404,
    );
  }

  // Resolve ref to use
  const ref = resolvedRef ?? (await client.getDefaultBranch(parsed.owner, parsed.repo));
  logger.debug(`Using ref: ${ref}`);

  // If target is a tool format, use conversion flow
  if (isToolTarget(target)) {
    return fetchAndConvertToolFiles({
      client,
      parsed,
      ref,
      resolvedPath,
      enabledFeatures,
      target,
      outputDir,
      outputRoot,
      conflictStrategy,
      logger,
    });
  }

  // Create semaphore for concurrency control
  const semaphore = new Semaphore(FETCH_CONCURRENCY_LIMIT);

  // Collect all files to fetch from feature directories directly
  const filesToFetch = await collectFeatureFiles({
    client,
    owner: parsed.owner,
    repo: parsed.repo,
    basePath: resolvedPath,
    ref,
    enabledFeatures,
    semaphore,
    logger,
  });

  if (filesToFetch.length === 0) {
    logger.warn(`No files found matching enabled features: ${enabledFeatures.join(", ")}`);
    return {
      source: `${parsed.owner}/${parsed.repo}`,
      ref,
      files: [],
      created: 0,
      overwritten: 0,
      skipped: 0,
    };
  }

  // Process files in parallel with concurrency control
  const outputBasePath = join(outputRoot, outputDir);

  // Validate paths and check file sizes first (synchronous checks)
  for (const { relativePath, size } of filesToFetch) {
    checkPathTraversal({
      relativePath,
      intendedRootDir: outputBasePath,
    });

    validateFileSize(relativePath, size);
  }

  // Process files in parallel with concurrency control
  // Note: Promise.all fails fast - if any promise rejects, others continue running but
  // may have already written files. This behavior is consistent with sequential execution,
  // but the window for partial writes is larger with parallel execution.
  const results = await Promise.all(
    filesToFetch.map(async ({ remotePath, relativePath }) => {
      const localPath = join(outputBasePath, relativePath);
      const exists = await fileExists(localPath);

      if (exists && conflictStrategy === "skip") {
        logger.debug(`Skipping existing file: ${relativePath}`);
        return { relativePath, status: "skipped" as const };
      }

      const content = await withSemaphore(semaphore, () =>
        client.getFileContent(parsed.owner, parsed.repo, remotePath, ref),
      );
      await writeFileContent(localPath, content);

      const status = exists ? ("overwritten" as const) : ("created" as const);
      logger.debug(`Wrote: ${relativePath} (${status})`);
      return { relativePath, status };
    }),
  );

  // Calculate summary
  const summary: FetchSummary = {
    source: `${parsed.owner}/${parsed.repo}`,
    ref,
    files: results,
    created: results.filter((r) => r.status === "created").length,
    overwritten: results.filter((r) => r.status === "overwritten").length,
    skipped: results.filter((r) => r.status === "skipped").length,
  };

  return summary;
}

/**
 * Collect files from feature directories
 */
async function collectFeatureFiles(params: {
  client: GitHubClient;
  owner: string;
  repo: string;
  basePath: string;
  ref: string;
  enabledFeatures: Feature[];
  semaphore: Semaphore;
  logger: Logger;
}): Promise<Array<{ remotePath: string; relativePath: string; size: number }>> {
  const { client, owner, repo, basePath, ref, enabledFeatures, semaphore, logger } = params;

  // Cache directory listing results to avoid duplicate API calls
  // File-based features (ignore, mcp, hooks) all list the same basePath directory
  const dirCache = new Map<string, Promise<GitHubFileEntry[]>>();

  async function getCachedDirectory(path: string): Promise<GitHubFileEntry[]> {
    let promise = dirCache.get(path);
    if (promise === undefined) {
      promise = withSemaphore(semaphore, () => client.listDirectory(owner, repo, path, ref));
      dirCache.set(path, promise);
    }
    return promise;
  }

  const tasks = enabledFeatures.flatMap((feature) =>
    FEATURE_PATHS[feature].map((featurePath) => ({ feature, featurePath })),
  );

  const results = await Promise.all(
    tasks.map(async ({ featurePath }) => {
      const fullPath =
        basePath === "." || basePath === "" ? featurePath : posix.join(basePath, featurePath);
      const collected: Array<{ remotePath: string; relativePath: string; size: number }> = [];

      try {
        // Check if it's a file (mcp.json, .aiignore, hooks.json)
        if (featurePath.includes(".")) {
          // Try to get the file directly
          try {
            const entries = await getCachedDirectory(
              basePath === "." || basePath === "" ? "." : basePath,
            );
            const fileEntry = entries.find((e) => e.name === featurePath && e.type === "file");
            if (fileEntry) {
              collected.push({
                remotePath: fileEntry.path,
                relativePath: featurePath,
                size: fileEntry.size,
              });
            }
          } catch (error) {
            // Only skip 404 errors (file not found), re-throw other errors
            if (isNotFoundError(error)) {
              logger.debug(`File not found: ${fullPath}`);
            } else {
              throw error;
            }
          }
        } else {
          // It's a directory (rules/, commands/, skills/, subagents/)
          const dirFiles = await listDirectoryRecursive({
            client,
            owner,
            repo,
            path: fullPath,
            ref,
            semaphore,
          });

          for (const file of dirFiles) {
            // Calculate relative path from base
            const relativePath =
              basePath === "." || basePath === ""
                ? file.path
                : file.path.substring(basePath.length + 1);

            collected.push({
              remotePath: file.path,
              relativePath,
              size: file.size,
            });
          }
        }
      } catch (error) {
        // Check for 404 errors (feature not found)
        if (isNotFoundError(error)) {
          // Feature directory/file not found, skip silently
          logger.debug(`Feature not found: ${fullPath}`);
          return collected;
        }
        throw error;
      }

      return collected;
    }),
  );

  return results.flat();
}

/**
 * Fetch tool-specific files and convert them to rulesync format
 */
async function fetchAndConvertToolFiles(params: {
  client: GitHubClient;
  parsed: ParsedSource;
  ref: string;
  resolvedPath: string;
  enabledFeatures: Feature[];
  target: ToolTarget;
  outputDir: string;
  outputRoot: string;
  conflictStrategy: ConflictStrategy;
  logger: Logger;
}): Promise<FetchSummary> {
  const {
    client,
    parsed,
    ref,
    resolvedPath,
    enabledFeatures,
    target,
    outputDir,
    outputRoot,
    conflictStrategy: _conflictStrategy,
    logger,
  } = params;

  // Create a unique temporary directory
  const tempDir = await createTempDirectory();
  logger.debug(`Created temp directory: ${tempDir}`);

  // Create semaphore for concurrency control
  const semaphore = new Semaphore(FETCH_CONCURRENCY_LIMIT);

  try {
    // Collect files using rulesync feature paths (rules/, commands/, etc.)
    // External repos use these paths directly without tool-specific prefixes
    const filesToFetch = await collectFeatureFiles({
      client,
      owner: parsed.owner,
      repo: parsed.repo,
      basePath: resolvedPath,
      ref,
      enabledFeatures,
      semaphore,
      logger,
    });

    if (filesToFetch.length === 0) {
      logger.warn(`No files found matching enabled features: ${enabledFeatures.join(", ")}`);
      return {
        source: `${parsed.owner}/${parsed.repo}`,
        ref,
        files: [],
        created: 0,
        overwritten: 0,
        skipped: 0,
      };
    }

    // Validate file sizes first
    for (const { relativePath, size } of filesToFetch) {
      validateFileSize(relativePath, size);
    }

    // Fetch files to temp directory with tool-specific structure in parallel
    // Map rulesync paths to tool-specific paths
    const toolPaths = getToolPathMapping(target);

    await Promise.all(
      filesToFetch.map(async ({ remotePath, relativePath }) => {
        // Map the relative path to tool-specific structure
        const toolRelativePath = mapToToolPath(relativePath, toolPaths);
        checkPathTraversal({
          relativePath: toolRelativePath,
          intendedRootDir: tempDir,
        });
        const localPath = join(tempDir, toolRelativePath);

        // Fetch file content with concurrency control, then write locally
        const content = await withSemaphore(semaphore, () =>
          client.getFileContent(parsed.owner, parsed.repo, remotePath, ref),
        );
        await writeFileContent(localPath, content);
        logger.debug(`Fetched to temp: ${toolRelativePath}`);
      }),
    );

    // Convert fetched files to rulesync format
    const outputBasePath = join(outputRoot, outputDir);
    const { converted, convertedPaths } = await convertFetchedFilesToRulesync({
      tempDir,
      outputDir: outputBasePath,
      target,
      features: enabledFeatures,
      logger,
    });

    // Build results based on conversion with actual file paths
    const results: FetchFileResult[] = convertedPaths.map((relativePath) => ({
      relativePath,
      status: "created" as const,
    }));

    logger.debug(`Converted ${converted} files from ${target} format to rulesync format`);

    return {
      source: `${parsed.owner}/${parsed.repo}`,
      ref,
      files: results,
      created: results.filter((r) => r.status === "created").length,
      overwritten: results.filter((r) => r.status === "overwritten").length,
      skipped: results.filter((r) => r.status === "skipped").length,
    };
  } finally {
    // Clean up temp directory
    await removeTempDirectory(tempDir);
  }
}

/**
 * Get tool-specific path mapping for a target
 * Returns a mapping from rulesync feature paths to tool-specific paths
 */
function getToolPathMapping(target: ToolTarget): {
  rules?: { root?: string; nonRoot?: string };
  commands?: string;
  subagents?: string;
  skills?: string;
} {
  // Get tool-specific paths from each processor class
  const mapping: {
    rules?: { root?: string; nonRoot?: string };
    commands?: string;
    subagents?: string;
    skills?: string;
  } = {};

  // Rules paths
  const supportedRulesTargets = RulesProcessor.getToolTargets({ global: false });
  if (supportedRulesTargets.includes(target)) {
    const factory = RulesProcessor.getFactory(target);
    if (factory) {
      const paths = factory.class.getSettablePaths({ global: false });
      mapping.rules = {
        root: paths.root?.relativeFilePath,
        nonRoot: paths.nonRoot?.relativeDirPath,
      };
    }
  }

  // Commands paths
  const supportedCommandsTargets = CommandsProcessor.getToolTargets({
    global: false,
    includeSimulated: false,
  });
  if (supportedCommandsTargets.includes(target)) {
    const factory = CommandsProcessor.getFactory(target);
    if (factory) {
      const paths = factory.class.getSettablePaths({ global: false });
      mapping.commands = paths.relativeDirPath;
    }
  }

  // Subagents paths
  const supportedSubagentsTargets = SubagentsProcessor.getToolTargets({
    global: false,
    includeSimulated: false,
  });
  if (supportedSubagentsTargets.includes(target)) {
    const factory = SubagentsProcessor.getFactory(target);
    if (factory) {
      const paths = factory.class.getSettablePaths({ global: false });
      mapping.subagents = paths.relativeDirPath;
    }
  }

  // Skills paths
  const supportedSkillsTargets = SkillsProcessor.getToolTargets({ global: false });
  if (supportedSkillsTargets.includes(target)) {
    const factory = SkillsProcessor.getFactory(target);
    if (factory) {
      const paths = factory.class.getSettablePaths({ global: false });
      mapping.skills = paths.relativeDirPath;
    }
  }

  return mapping;
}

/**
 * Map a rulesync-style relative path to tool-specific path
 */
function mapToToolPath(
  relativePath: string,
  toolPaths: ReturnType<typeof getToolPathMapping>,
): string {
  // Check if this is a rules file
  if (relativePath.startsWith("rules/")) {
    const restPath = relativePath.substring("rules/".length);
    if (toolPaths.rules?.nonRoot) {
      return join(toolPaths.rules.nonRoot, restPath);
    }
  }

  // Check if this is a root rule file (e.g., CLAUDE.md, AGENTS.md)
  if (toolPaths.rules?.root && relativePath === toolPaths.rules.root) {
    return relativePath;
  }

  // Check if this is a commands file
  if (relativePath.startsWith("commands/")) {
    const restPath = relativePath.substring("commands/".length);
    if (toolPaths.commands) {
      return join(toolPaths.commands, restPath);
    }
  }

  // Check if this is a subagents file
  if (relativePath.startsWith("subagents/")) {
    const restPath = relativePath.substring("subagents/".length);
    if (toolPaths.subagents) {
      return join(toolPaths.subagents, restPath);
    }
  }

  // Check if this is a skills file
  if (relativePath.startsWith("skills/")) {
    const restPath = relativePath.substring("skills/".length);
    if (toolPaths.skills) {
      return join(toolPaths.skills, restPath);
    }
  }

  // Default: return as-is
  return relativePath;
}

/**
 * Format fetch summary for display
 */
export function formatFetchSummary(summary: FetchSummary): string {
  const lines: string[] = [];

  lines.push(`Fetched from ${summary.source}@${summary.ref}:`);

  for (const file of summary.files) {
    const icon = file.status === "skipped" ? "-" : "\u2713";
    const statusText =
      file.status === "created"
        ? "(created)"
        : file.status === "overwritten"
          ? "(overwritten)"
          : "(skipped - already exists)";
    lines.push(`  ${icon} ${file.relativePath} ${statusText}`);
  }

  const parts: string[] = [];
  if (summary.created > 0) parts.push(`${summary.created} created`);
  if (summary.overwritten > 0) parts.push(`${summary.overwritten} overwritten`);
  if (summary.skipped > 0) parts.push(`${summary.skipped} skipped`);

  lines.push("");
  const summaryText = parts.length > 0 ? parts.join(", ") : "no files";
  lines.push(`Summary: ${summaryText}`);

  return lines.join("\n");
}

// Legacy export for backward compatibility during migration
export { fetchFiles as fetchFromGitHub };
