// This module contains the logic for codefixes that FSAC surfaces, as well as conversion logic between
// compiler diagnostics and LSP diagnostics/code actions
namespace FsAutoComplete.CodeFix

open FsAutoComplete
open FsAutoComplete.LspHelpers
open Ionide.LanguageServerProtocol.Types
open FsAutoComplete.Logging
open FSharp.UMX
open FsToolkit.ErrorHandling
open FSharp.Compiler.Text

module FcsRange = FSharp.Compiler.Text.Range
type FcsRange = FSharp.Compiler.Text.Range
type FcsPos = FSharp.Compiler.Text.Position

type LspRange = Ionide.LanguageServerProtocol.Types.Range
type LspPosition = Ionide.LanguageServerProtocol.Types.Position

module Types =
  open FsAutoComplete.FCSPatches
  open System.Threading.Tasks
  open FSharp.Compiler.CodeAnalysis.ProjectSnapshot

  type IsEnabled = unit -> bool

  type GetRangeText = string<LocalPath> -> LspRange -> Async<ResultOrString<string>>
  type GetFileLines = string<LocalPath> -> Async<ResultOrString<IFSACSourceText>>
  type GetLineText = IFSACSourceText -> LspRange -> Async<Result<string, string>>

  type GetParseResultsForFile =
    string<LocalPath>
      -> FSharp.Compiler.Text.Position
      -> Async<ResultOrString<ParseAndCheckResults * string * IFSACSourceText>>

  type GetLanguageVersion = string<LocalPath> -> Async<LanguageVersionShim>

  type GetProjectOptionsForFile = string<LocalPath> -> Async<ResultOrString<CompilerProjectOption>>

  [<RequireQualifiedAccess>]
  [<Struct>]
  type FixKind =
    | Fix
    | Refactor
    | Rewrite

  type Fix =
    { Edits: TextEdit[]
      File: TextDocumentIdentifier
      Title: string
      SourceDiagnostic: Diagnostic option
      Kind: FixKind }

  type CodeFix = CodeActionParams -> Async<Result<Fix list, string>>

  type CodeAction with

    static member OfFix getFileVersion clientCapabilities (fix: Fix) =
      async {
        let filePath = fix.File.GetFilePath() |> Utils.normalizePath
        let! fileVersion = getFileVersion filePath

        return
          CodeAction.OfDiagnostic
            fix.File
            fileVersion
            fix.Title
            fix.SourceDiagnostic
            fix.Edits
            fix.Kind
            clientCapabilities
      }

    static member OfDiagnostic
      (fileUri)
      (fileVersion)
      title
      (diagnostic)
      (edits)
      fixKind
      clientCapabilities
      : CodeAction =

      let edit =
        { TextDocument =
            { Uri = fileUri.Uri
              Version = fileVersion }
          Edits = edits |> Array.map U2.C1 }

      let workspaceEdit = WorkspaceEdit.Create([| edit |], clientCapabilities)

      { CodeAction.Title = title
        Kind =
          Some(
            match fixKind with
            | FixKind.Fix -> "quickfix"
            | FixKind.Refactor -> "refactor"
            | FixKind.Rewrite -> "refactor.rewrite"
          )
        Diagnostics = diagnostic |> Option.map Array.singleton
        IsPreferred = None
        Disabled = None
        Edit = Some workspaceEdit
        Command = None
        Data = None }

module SourceText =
  let inline private assertLineIndex lineIndex (sourceText: ISourceText) =
    assert (0 <= lineIndex && lineIndex < sourceText.GetLineCount())

  /// Note: this fails when `sourceText` is empty string (`""`)
  /// -> No lines
  ///    Use `WithEmptyHandling.isFirstLine` to handle empty string
  let isFirstLine lineIndex (sourceText: ISourceText) =
    assertLineIndex lineIndex sourceText
    lineIndex = 0

  /// Note: this fails when `sourceText` is empty string (`""`)
  /// -> No lines
  ///    Use `WithEmptyHandling.isLastLine` to handle empty string
  let isLastLine lineIndex (sourceText: ISourceText) =
    assertLineIndex lineIndex sourceText
    lineIndex = sourceText.GetLineCount() - 1

  /// SourceText treats empty string as no source:
  /// ```fsharp
  /// let text = SourceText.ofString ""
  /// assert(text.ToString() = "")
  /// assert(text.GetLastCharacterPosition() = (0, 0))  // Note: first line is `1`
  /// assert(text.GetLineCount() = 0) // Note: `(SourceText.ofString "\n").GetLineCount()` is `2`
  /// assert(text.GetLineString 0 )  // System.IndexOutOfRangeException: Index was outside the bounds of the array.
  /// ```
  /// -> Functions in here treat empty string as empty single line
  ///
  /// Note: There's always at least empty single line
  ///       -> source MUST at least be empty (cannot not exist)
  module WithEmptyHandling =
    let getLineCount (sourceText: ISourceText) =
      match sourceText.GetLineCount() with
      | 0 -> 1u
      | c -> uint32 c
    // or
    // max 1 (sourceText.GetLineCount())

    let inline private assertLineIndex (lineIndex: uint32) sourceText = assert (lineIndex < getLineCount sourceText)

    let getLineString lineIndex (sourceText: ISourceText) =
      assertLineIndex lineIndex sourceText

      if lineIndex = 0u && sourceText.GetLineCount() = 0 then
        ""
      else
        sourceText.GetLineString(int lineIndex)

    let isFirstLine (lineIndex: uint32) (sourceText: ISourceText) =
      assertLineIndex lineIndex sourceText
      // No need to check for inside `getLineCount`: there's always at least one line (might be empty)
      lineIndex = 0u

    let isLastLine lineIndex (sourceText: ISourceText) =
      assertLineIndex lineIndex sourceText
      lineIndex = (getLineCount sourceText) - 1u

    /// Returns position after last character in specified line.
    /// Same as line length.
    ///
    /// Example:
    /// ```fsharp
    /// let text = SourceText.ofString "let a = 2\nlet foo = 42\na + foo\n"
    ///
    /// assert(afterLastCharacterPosition 0 text = 9)
    /// assert(afterLastCharacterPosition 1 text = 12)
    /// assert(afterLastCharacterPosition 2 text = 7)
    /// assert(afterLastCharacterPosition 2 text = 0)
    /// ```
    let afterLastCharacterPosition lineIndex (sourceText: ISourceText) =
      assertLineIndex lineIndex sourceText
      let line = sourceText |> getLineString lineIndex
      uint32 line.Length

/// helpers for iterating along text lines
module Navigation =

  let findPosForCharacter (lines: string[]) (pos: uint32) =
    let mutable lineNumber = 0u
    let mutable runningLength = 0u
    let mutable found = false

    let mutable fcsPos = Unchecked.defaultof<FcsPos>

    while not found do
      let line = lines.[int lineNumber]
      let lineLength = uint32 line.Length

      if pos <= runningLength + lineLength then
        let column = pos - runningLength
        found <- true
        fcsPos <- FSharp.Compiler.Text.Position.mkPos (int lineNumber) (int column)
      else
        lineNumber <- lineNumber + 1u
        runningLength <- runningLength + lineLength

    fcsPos

  let inc (lines: IFSACSourceText) (pos: LspPosition) : LspPosition option =
    lines.NextPos(protocolPosToPos pos) |> Option.map fcsPosToLsp

  let dec (lines: IFSACSourceText) (pos: LspPosition) : LspPosition option =
    lines.PrevPos(protocolPosToPos pos) |> Option.map fcsPosToLsp

  let rec decMany lines pos count =
    option {
      let mutable pos = pos
      let mutable count = count

      while count > 0u do
        let! nextPos = dec lines pos
        pos <- nextPos
        count <- count - 1u

      return pos
    }

  let rec incMany lines pos count =
    option {
      let mutable pos = pos
      let mutable count = count

      while count > 0u do
        let! nextPos = inc lines pos
        pos <- nextPos
        count <- count - 1u

      return pos
    }

  let walkBackUntilConditionWithTerminal (lines: IFSACSourceText) (pos: LspPosition) condition terminal =
    let fcsStartPos = protocolPosToPos pos

    lines.WalkBackwards(fcsStartPos, terminal, condition) |> Option.map fcsPosToLsp

  let walkForwardUntilConditionWithTerminal (lines: IFSACSourceText) (pos: LspPosition) condition terminal =
    let fcsStartPos = protocolPosToPos pos


    lines.WalkForward(fcsStartPos, terminal, condition) |> Option.map fcsPosToLsp

  let walkBackUntilCondition lines pos condition =
    walkBackUntilConditionWithTerminal lines pos condition (fun _ -> false)

  let walkForwardUntilCondition lines pos condition =
    walkForwardUntilConditionWithTerminal lines pos condition (fun _ -> false)

  /// Tries to detect the last cursor position in line before `currentLine` (0-based).
  ///
  /// Returns `None` iff there's no prev line -> `currentLine` is first line
  let tryEndOfPrevLine (lines: IFSACSourceText) (currentLine: uint32) =
    if SourceText.WithEmptyHandling.isFirstLine currentLine lines then
      None
    else
      let prevLine = currentLine - 1u

      { Line = uint32 prevLine
        Character = lines |> SourceText.WithEmptyHandling.afterLastCharacterPosition prevLine }
      |> Some

  /// Tries to detect the first cursor position in line after `currentLine` (0-based).
  ///
  /// Returns `None` iff there's no next line -> `currentLine` is last line
  let tryStartOfNextLine (lines: IFSACSourceText) currentLine =
    if SourceText.WithEmptyHandling.isLastLine currentLine lines then
      None
    else
      let nextLine = currentLine + 1u

      { Line = uint32 nextLine
        Character = 0u }
      |> Some

  /// Gets the range to delete the complete line `lineIndex` (0-based).
  /// Deleting the line includes a linebreak if possible
  /// -> range starts either at end of previous line (-> includes leading linebreak)
  ///    or start of next line (-> includes trailing linebreak)
  ///
  /// Special case: there's just one line
  /// -> delete text of (single) line
  let rangeToDeleteFullLine lineIndex (lines: IFSACSourceText) =
    match tryEndOfPrevLine lines lineIndex with
    | Some start ->
      // delete leading linebreak
      { Start = start
        End =
          { Line = lineIndex
            Character = lines |> SourceText.WithEmptyHandling.afterLastCharacterPosition lineIndex } }
    | None ->
      match tryStartOfNextLine lines lineIndex with
      | Some fin ->
        // delete trailing linebreak
        { Start = { Line = lineIndex; Character = 0u }
          End = fin }
      | None ->
        // single line
        // -> just delete all text in line
        { Start = { Line = lineIndex; Character = 0u }
          End =
            { Line = lineIndex
              Character = lines |> SourceText.WithEmptyHandling.afterLastCharacterPosition lineIndex } }



module Run =
  open Types

  let ifEnabled enabled codeFix : CodeFix =
    fun codeActionParams ->
      if enabled () then
        codeFix codeActionParams
      else
        AsyncResult.retn []

  let private runDiagnostics pred handler : CodeFix =
    fun codeActionParams ->
      codeActionParams.Context.Diagnostics
      |> Array.choose (fun d -> if pred d then Some d else None)
      |> Array.toList
      |> List.traverseAsyncResultM (fun d -> handler d codeActionParams)
      |> AsyncResult.map List.concat


  let ifDiagnosticByMessage (checkMessage: string) handler : CodeFix =
    runDiagnostics (fun d -> d.Message.Contains checkMessage) handler

  let ifDiagnosticByCheckMessage (checkMessageFunc: (string -> bool) list) handler : CodeFix =
    runDiagnostics (fun d -> checkMessageFunc |> List.exists (fun f -> f d.Message)) handler

  let ifDiagnosticByType (diagnosticType: string) handler : CodeFix =
    runDiagnostics
      (fun d ->
        match d.Source with
        | None -> false
        | Some s -> s.Contains diagnosticType)
      handler

  let ifDiagnosticByCode codes handler : CodeFix =
    runDiagnostics
      (fun d ->
        match d.CodeAsString with
        | Some c -> Set.contains c codes
        | None -> false)
      handler

  let ifImplementationFileBackedBySignature
    (getProjectOptionsForFile: GetProjectOptionsForFile)
    (codeFix: CodeFix)
    (codeActionParams: CodeActionParams)
    : Async<Result<Fix list, string>> =
    async {
      let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath
      let! project = getProjectOptionsForFile fileName

      match project with
      | Error _ -> return Ok []
      | Ok projectOptions ->

        let signatureFile = System.String.Concat(fileName, "i")

        let hasSig =
          projectOptions.SourceFilesTagged
          |> List.map (UMX.untag)
          |> List.contains signatureFile

        if not hasSig then
          return Ok []
        else
          return! codeFix codeActionParams
    }
