import { spawn } from "child_process";
import { ScanResults, Secret } from "./types";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { dirname, relative } from "path";
import { promisify } from "util";
import { exec } from "child_process";
import { ScanOptions } from "./types";

interface GitleaksResult {
  Description: string;
  StartLine: number;
  EndLine: number;
  StartColumn: number;
  EndColumn: number;
  Match: string;
  Secret: string;
  File: string;
  Commit: string;
  Entropy: number;
  Author: string;
  Email: string;
  Date: string;
  Message: string;
  Tags: string[];
  RuleID: string;
  Fingerprint: string;
}

/**
 * Git blame information about a specific line
 */
interface GitBlameInfo {
  author: string;
  email: string;
  date: string;
  commit: string;
  message: string;
}

/**
 * Scan a local directory for secrets using Gitleaks
 * @param directory The directory to scan
 * @param scanGitHistory Whether to scan git history (commits)
 * @returns Scan results
 */
export async function runGitleaksScan(
  directory: string,
  scanOptions?: ScanOptions
): Promise<ScanResults> {
  const args = ["detect", "--source", directory];

  // Only add --no-git if we're not scanning git history
  if (!scanOptions?.scanGitHistory) {
    args.push("--no-git");
  }

  // Add report format options
  args.push("--report-format", "json", "--report-path", "-");

  return new Promise((resolve, reject) => {
    const gitleaks = spawn("gitleaks", args);

    let output = "";
    let errorOutput = "";

    gitleaks.stdout.on("data", (data) => {
      output += data.toString();
    });

    gitleaks.stderr.on("data", (data) => {
      errorOutput += data.toString();
    });

    gitleaks.on("close", async (code) => {
      // Gitleaks returns exit code 1 when it finds secrets, which is not an error
      // Exit code 0 means no secrets found, exit code 1 means secrets found
      if (code !== 0 && code !== 1) {
        reject(new Error(`Gitleaks failed with code ${code}: ${errorOutput}`));
        return;
      }

      try {
        // If there's output, try to parse it
        if (output.trim()) {
          const results: GitleaksResult[] = JSON.parse(output);
          const secrets: Secret[] = [];

          for (const result of results) {
            // Skip node_modules files unless explicitly included
            if (
              (!scanOptions?.includeNodeModules &&
                (result.File.includes("/node_modules/") ||
                  result.File.includes("\\node_modules\\"))) ||
              result.File.includes("/.next/") ||
              result.File.includes("\\.next\\") ||
              result.File.endsWith("package-lock.json") ||
              result.File.endsWith("yarn.lock") ||
              result.File.endsWith("pnpm-lock.yaml")
            ) {
              if (scanOptions?.verbose) {
                if (result.File.includes("node_modules")) {
                  console.log(`Skipping node_modules result: ${result.File}`);
                } else if (result.File.includes(".next")) {
                  console.log(
                    `Skipping .next build directory result: ${result.File}`
                  );
                } else if (
                  result.File.endsWith("package-lock.json") ||
                  result.File.endsWith("yarn.lock") ||
                  result.File.endsWith("pnpm-lock.yaml")
                ) {
                  console.log(`Skipping dependency lock file: ${result.File}`);
                }
              }
              continue;
            }

            // Get git blame information for this line
            const blameInfo = await getGitBlameInfo(
              path.join(directory, result.File),
              result.StartLine,
              scanOptions
            );

            secrets.push({
              file: result.File,
              line: result.StartLine,
              types: [result.RuleID],
              is_false_positive: false,
              hashed_secret: "",
              author: blameInfo.author,
              email: blameInfo.email,
              date: blameInfo.date,
              commit: blameInfo.commit,
              message: blameInfo.message,
            });
          }

          resolve({
            secrets: secrets,
            missed_secrets: [],
          });
        } else {
          // No output means no secrets found
          resolve({
            secrets: [],
            missed_secrets: [],
          });
        }
      } catch (error: unknown) {
        const errorMessage =
          error instanceof Error ? error.message : String(error);
        reject(new Error(`Failed to parse Gitleaks output: ${errorMessage}`));
      }
    });

    gitleaks.on("error", (error) => {
      if (error.message.includes("ENOENT")) {
        reject(
          new Error(
            "Gitleaks is not installed. Please install Gitleaks first: https://github.com/zricethezav/gitleaks#installation"
          )
        );
      } else {
        reject(error);
      }
    });
  });
}

/**
 * Clone and scan a remote repository for secrets
 * @param repoUrl URL of the Git repository to scan
 * @param branch Optional branch to check out
 * @returns Scan results
 */
export async function scanRemoteRepository(
  repoUrl: string,
  scanOptions?: ScanOptions
): Promise<ScanResults> {
  // Create temporary directory
  const tmpDir = path.join(os.tmpdir(), `detect-secrets-js-${Date.now()}`);

  // Clone the repository
  await new Promise<void>((resolve, reject) => {
    const git = spawn("git", ["clone", repoUrl, tmpDir]);

    git.on("close", (code) => {
      if (code !== 0) {
        reject(new Error(`Failed to clone repository: ${repoUrl}`));
        return;
      }
      resolve();
    });

    git.on("error", (error) => {
      reject(error);
    });
  });

  return new Promise((resolve, reject) => {
    // Scan the cloned repository
    try {
      // Pass scanOptions to runGitleaksScan, and set scanGitHistory to true
      const updatedOptions = {
        ...scanOptions,
        scanGitHistory: true,
      };

      runGitleaksScan(tmpDir, updatedOptions)
        .then(resolve)
        .catch(reject)
        .finally(() => {
          // Clean up temporary directory using cross-platform Node.js fs.rmSync
          try {
            if (fs.existsSync(tmpDir)) {
              fs.rmSync(tmpDir, { recursive: true, force: true });
            }
          } catch (error) {
            console.warn(
              `Failed to clean up temporary directory: ${
                error instanceof Error ? error.message : String(error)
              }`
            );
          }
        });
    } catch (error) {
      reject(error);
    }
  });
}

/**
 * Scan Git history (commits) for secrets in a local repository
 * @param directory The directory containing the Git repository
 * @param fromCommit Optional starting commit hash
 * @param toCommit Optional ending commit hash
 * @returns Scan results
 */
export async function scanGitHistory(
  directory: string,
  fromCommit?: string,
  toCommit?: string,
  scanOptions?: ScanOptions
): Promise<ScanResults> {
  const args = ["detect", "--source", directory];

  // Always include git history when scanning git history
  // Add commit range if specified
  if (fromCommit) {
    if (toCommit) {
      // Scan a range of commits
      args.push("--log-opts", `--all ${fromCommit}..${toCommit}`);
    } else {
      // Scan from a specific commit to HEAD
      args.push("--log-opts", `--all ${fromCommit}..HEAD`);
    }
  } else if (toCommit) {
    // If only toCommit is specified, scan up to that commit
    args.push("--log-opts", `--all ..${toCommit}`);
  } else {
    // Scan all history
    args.push("--log-opts", "--all");
  }

  // Add report format options
  args.push("--report-format", "json", "--report-path", "-");

  return runGitleaksWithArgs(args, scanOptions);
}

/**
 * Helper function to run Gitleaks with specified arguments
 * @param args Command line arguments for Gitleaks
 * @returns Scan results
 */
function runGitleaksWithArgs(
  args: string[],
  scanOptions?: ScanOptions
): Promise<ScanResults> {
  return new Promise((resolve, reject) => {
    const gitleaks = spawn("gitleaks", args);

    let output = "";
    let errorOutput = "";

    gitleaks.stdout.on("data", (data) => {
      output += data.toString();
    });

    gitleaks.stderr.on("data", (data) => {
      errorOutput += data.toString();
    });

    gitleaks.on("close", (code) => {
      // Gitleaks returns exit code 1 when it finds secrets, which is expected behavior
      // Exit code 0 means no secrets found
      // Any other code is a real error
      if (code !== 0 && code !== 1) {
        reject(new Error(`Gitleaks failed with code ${code}: ${errorOutput}`));
        return;
      }

      try {
        // If there's no output, return an empty result
        if (!output.trim()) {
          resolve({
            secrets: [],
            missed_secrets: [],
          });
          return;
        }

        // Parse the JSON output
        const results: GitleaksResult[] = JSON.parse(output);
        const secrets: Secret[] = [];

        for (const result of results) {
          // Skip node_modules files unless explicitly included
          if (
            (!scanOptions?.includeNodeModules &&
              (result.File.includes("/node_modules/") ||
                result.File.includes("\\node_modules\\"))) ||
            result.File.includes("/.next/") ||
            result.File.includes("\\.next\\") ||
            result.File.endsWith("package-lock.json") ||
            result.File.endsWith("yarn.lock") ||
            result.File.endsWith("pnpm-lock.yaml")
          ) {
            if (scanOptions?.verbose) {
              if (result.File.includes("node_modules")) {
                console.log(`Skipping node_modules result: ${result.File}`);
              } else if (result.File.includes(".next")) {
                console.log(
                  `Skipping .next build directory result: ${result.File}`
                );
              } else if (
                result.File.endsWith("package-lock.json") ||
                result.File.endsWith("yarn.lock") ||
                result.File.endsWith("pnpm-lock.yaml")
              ) {
                console.log(`Skipping dependency lock file: ${result.File}`);
              }
            }
            continue;
          }

          // Convert Gitleaks result to our Secret format
          secrets.push({
            file: result.File,
            line: result.StartLine,
            types: [result.RuleID],
            is_false_positive: false,
            hashed_secret: "",
            author: result.Author || "",
            email: result.Email || "",
            date: result.Date || "",
            commit: result.Commit || "",
            message: result.Message || "",
          });
        }

        resolve({
          secrets,
          missed_secrets: [],
        });
      } catch (error) {
        reject(
          new Error(
            `Failed to parse Gitleaks output: ${
              error instanceof Error ? error.message : String(error)
            }`
          )
        );
      }
    });

    gitleaks.on("error", (error) => {
      if (error.message.includes("ENOENT")) {
        reject(
          new Error(
            "Gitleaks is not installed. Please install Gitleaks first: https://github.com/zricethezav/gitleaks#installation"
          )
        );
      } else {
        reject(error);
      }
    });
  });
}

/**
 * Get git blame information for a specific file and line
 * @param filePath Path to the file
 * @param lineNumber Line number to blame
 * @param scanOptions Optional scan options
 * @returns Object with author, email, date, and commit message
 */
export async function getGitBlameInfo(
  filePath: string,
  lineNumber: number,
  scanOptions?: ScanOptions
): Promise<GitBlameInfo> {
  try {
    // Skip git blame for node_modules files
    if (
      filePath.includes("/node_modules/") ||
      filePath.includes("\\node_modules\\")
    ) {
      return {
        author: "NodeModule",
        email: "npm-package",
        date: "Unknown",
        commit: "N/A",
        message: "Third-party module dependency",
      };
    }

    // Skip git blame for Next.js build files
    if (filePath.includes("/.next/") || filePath.includes("\\.next\\")) {
      return {
        author: "NextJS",
        email: "build-output",
        date: "Unknown",
        commit: "N/A",
        message: "Next.js build output",
      };
    }

    // Skip git blame for lock files
    if (
      filePath.endsWith("package-lock.json") ||
      filePath.endsWith("yarn.lock") ||
      filePath.endsWith("pnpm-lock.yaml")
    ) {
      return {
        author: "PackageManager",
        email: "auto-generated",
        date: "Unknown",
        commit: "N/A",
        message: "Auto-generated dependency lock file",
      };
    }

    // Determine which git repository path to use
    let repoPath = "";
    if (scanOptions?.gitRepoPath) {
      // Use the specified git repository path
      repoPath = scanOptions.gitRepoPath;
    } else {
      // Try to find the repository root for the file
      try {
        // Get the directory of the file
        const fileDir = dirname(filePath);

        // Find the git repository root for this file
        const { stdout: gitRoot } = await promisify(exec)(
          "git rev-parse --show-toplevel",
          {
            cwd: fileDir,
          }
        );

        repoPath = gitRoot.trim();
      } catch (error) {
        // If we can't determine the repository root, use the current directory
        repoPath = process.cwd();
      }
    }

    // Get the relative path of the file to the repository root
    let relativeFilePath = filePath;
    try {
      // Only calculate relative path if repoPath is valid and different from current directory
      if (repoPath && repoPath !== process.cwd()) {
        relativeFilePath = relative(repoPath, filePath);
      }
    } catch (error) {
      // If we can't determine the relative path, use the original file path
      relativeFilePath = filePath;
    }

    // Run git blame to get information about who last modified this line
    const { stdout: blameOutput } = await promisify(exec)(
      `git blame -L ${lineNumber},${lineNumber} --porcelain "${relativeFilePath}"`,
      { cwd: repoPath }
    );

    // Parse the output to extract author, email, date, etc.
    const commitHash = blameOutput.split("\n")[0].split(" ")[0];
    const authorLine = blameOutput
      .split("\n")
      .find((line) => line.startsWith("author "));
    const emailLine = blameOutput
      .split("\n")
      .find((line) => line.startsWith("author-mail "));
    const dateLine = blameOutput
      .split("\n")
      .find((line) => line.startsWith("author-time "));

    const author = authorLine ? authorLine.replace("author ", "") : "Unknown";
    const email = emailLine
      ? emailLine.replace("author-mail ", "").replace(/[<>]/g, "")
      : "Unknown";
    const timestamp = dateLine
      ? parseInt(dateLine.replace("author-time ", ""), 10) * 1000
      : 0;
    const date = timestamp ? new Date(timestamp).toISOString() : "Unknown";

    // Get the commit message
    const { stdout: messageOutput } = await promisify(exec)(
      `git show -s --format=%B ${commitHash}`,
      { cwd: repoPath }
    );

    const message = messageOutput.trim();

    return {
      author,
      email,
      date,
      commit: commitHash,
      message,
    };
  } catch (error) {
    // Return default values if git blame fails
    return {
      author: "Unknown",
      email: "Unknown",
      date: "Unknown",
      commit: "Unknown",
      message: "Unknown",
    };
  }
}

/**
 * Enrich secrets with git blame information
 * @param secrets Array of secrets to enrich
 * @param scanOptions Optional scan options
 * @returns Enriched secrets with author information
 */
export async function enrichSecretsWithBlameInfo(
  secrets: Secret[],
  scanOptions?: ScanOptions
): Promise<Secret[]> {
  const enrichedSecrets = [];

  for (const secret of secrets) {
    try {
      const blameInfo = await getGitBlameInfo(
        secret.file,
        secret.line,
        scanOptions
      );

      // Add blame info to the secret
      enrichedSecrets.push({
        ...secret,
        author: blameInfo.author,
        email: blameInfo.email,
        date: blameInfo.date,
        commit: blameInfo.commit,
        message: blameInfo.message,
      });
    } catch (error) {
      // If blame fails, keep the original secret
      enrichedSecrets.push(secret);
    }
  }

  return enrichedSecrets;
}
