import * as cp from "child_process";
import * as path from "path";
import {
  window,
  DiagnosticCollection,
  Diagnostic,
  Range,
  Position,
  DiagnosticSeverity,
  Uri,
  CodeAction,
  CodeActionKind,
  WorkspaceEdit,
  OutputChannel,
} from "vscode";
import { analysisProdPath, getAnalysisBinaryPath } from "../utils";

export type DiagnosticsResultCodeActionsMap = Map<
  string,
  { range: Range; codeAction: CodeAction }[]
>;

export type DiagnosticsResultFormat = Array<{
  name: string;
  kind: string;
  file: string;
  range: [number, number, number, number];
  message: string;
  annotate?: {
    line: number;
    character: number;
    text: string;
    action: string;
  };
}>;

let resultsToDiagnostics = (
  results: DiagnosticsResultFormat,
  diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap
): {
  diagnosticsMap: Map<string, Diagnostic[]>;
} => {
  let diagnosticsMap: Map<string, Diagnostic[]> = new Map();

  results.forEach((item) => {
    {
      let startPos: Position, endPos: Position;
      let [startLine, startCharacter, endLine, endCharacter] = item.range;

      // Detect if this diagnostic is for the entire file. If so, reanalyze will
      // say that the issue is on line -1. This code below ensures
      // that the full file is highlighted, if that's the case.
      if (startLine < 0 || endLine < 0) {
        startPos = new Position(0, 0);
        endPos = new Position(99999, 0);
      } else {
        startPos = new Position(startLine, startCharacter);
        endPos = new Position(endLine, endCharacter);
      }

      let issueLocationRange = new Range(startPos, endPos);
      let diagnosticText = item.message.trim();

      let diagnostic = new Diagnostic(
        issueLocationRange,
        diagnosticText,
        DiagnosticSeverity.Warning
      );

      // Don't show reports about optional arguments.
      if (item.name.toLowerCase().includes("unused argument")) {
        return;
      }

      if (diagnosticsMap.has(item.file)) {
        diagnosticsMap.get(item.file).push(diagnostic);
      } else {
        diagnosticsMap.set(item.file, [diagnostic]);
      }

      // If reanalyze suggests a fix, we'll set that up as a refactor code
      // action in VSCode. This way, it'll be easy to suppress the issue
      // reported if wanted. We also save the range of the issue, so we can
      // leverage that to make looking up the code actions for each cursor
      // position very cheap.
      if (item.annotate != null) {
        {
          let { line, character, text, action } = item.annotate;
          let codeAction = new CodeAction(action);
          codeAction.kind = CodeActionKind.RefactorRewrite;

          let codeActionEdit = new WorkspaceEdit();

          // In the future, it would be cool to have an additional code action
          // here for automatically removing whatever the thing that's dead is.
          codeActionEdit.replace(
            Uri.parse(item.file),
            // Make sure the full line is replaced

            new Range(
              new Position(line, character),
              new Position(line, character)
            ),
            // reanalyze seems to add two extra spaces at the start of the line
            // content to replace.
            text
          );

          codeAction.edit = codeActionEdit;

          if (diagnosticsResultCodeActions.has(item.file)) {
            diagnosticsResultCodeActions
              .get(item.file)
              .push({ range: issueLocationRange, codeAction });
          } else {
            diagnosticsResultCodeActions.set(item.file, [
              { range: issueLocationRange, codeAction },
            ]);
          }
        }
      }
    }
  });

  return {
    diagnosticsMap,
  };
};

export const runCodeAnalysisWithReanalyze = (
  targetDir: string | null,
  diagnosticsCollection: DiagnosticCollection,
  diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap,
  outputChannel: OutputChannel
) => {
  let currentDocument = window.activeTextEditor.document;
  let cwd = targetDir ?? path.dirname(currentDocument.uri.fsPath);

  let binaryPath = getAnalysisBinaryPath();
  if (binaryPath === null) {
    window.showErrorMessage("Binary executable not found.", analysisProdPath);
    return;
  }

  let opts = ["reanalyze", "-json"];
  let p = cp.spawn(binaryPath, opts, {
    cwd,
  });

  if (p.stdout == null) {
    window.showErrorMessage("Something went wrong.");
    return;
  }

  let data = "";

  p.stdout.on("data", (d) => {
    data += d;
  });

  p.stderr?.on("data", (e) => {
    // Sometimes the compiler artifacts has been corrupted in some way, and
    // reanalyze will spit out a "End_of_file" exception. The solution is to
    // clean and rebuild the ReScript project, which we can tell the user about
    // here.
    if (e.includes("End_of_file")) {
      window.showErrorMessage(
        `Something went wrong trying to run reanalyze. Please try cleaning and rebuilding your ReScript project.`
      );
    } else {
      window.showErrorMessage(
        `Something went wrong trying to run reanalyze: '${e}'`
      );
    }
  });

  p.on("close", () => {
    diagnosticsResultCodeActions.clear();

    let json: DiagnosticsResultFormat | null = null;

    try {
      json = JSON.parse(data);
    } catch (e) {
      window
        .showErrorMessage(
          `Something went wrong when running the code analyzer.`,
          "See details in error log"
        )
        .then((_choice) => {
          outputChannel.show();
        });

      outputChannel.appendLine("\n\n>>>>");
      outputChannel.appendLine(
        "Parsing JSON from reanalyze failed. The raw, invalid JSON can be reproduced by following the instructions below. Please run that command and report the issue + failing JSON on the extension bug tracker: https://github.com/rescript-lang/rescript-vscode/issues"
      );
      outputChannel.appendLine(
        `> To reproduce, run "${binaryPath} ${opts.join(
          " "
        )}" in directory: "${cwd}"`
      );
      outputChannel.appendLine("\n");
    }

    if (json == null) {
      // If reanalyze failed for some reason we'll clear the diagnostics.
      diagnosticsCollection.clear();
      return;
    }

    let { diagnosticsMap } = resultsToDiagnostics(
      json,
      diagnosticsResultCodeActions
    );

    // This smoothens the experience of the diagnostics updating a bit by
    // clearing only the visible diagnostics that has been fixed after the
    // updated diagnostics has been applied.
    diagnosticsCollection.forEach((uri, _) => {
      if (!diagnosticsMap.has(uri.fsPath)) {
        diagnosticsCollection.delete(uri);
      }
    });

    diagnosticsMap.forEach((diagnostics, filePath) => {
      diagnosticsCollection.set(Uri.parse(filePath), diagnostics);
    });
  });
};