// Copyright (c) Microsoft Corporation.  All Rights Reserved.  See License.txt in the project root for license information.

namespace FSharp.Compiler.Interactive

open System
open System.Text
open System.Collections.Generic
open System.Runtime.InteropServices
open FSharp.Compiler.DiagnosticsLogger

[<AutoOpen>]
module internal ConsoleHelpers =

#if NO_CHECKNULLS
  type MaybeNull<'T when 'T : null> = 'T

  // Shim to match nullness checking library support in preview
  let inline (|Null|NonNull|) (x: 'T) : Choice<unit,'T> = match x with null -> Null | v -> NonNull v
#else
  type MaybeNull<'T when 'T : not null> = 'T | null
#endif

type internal Style =
    | Prompt
    | Out
    | Error

/// Class managing the command History.
type internal History() =
    let list = new List<string>()
    let mutable current = 0

    member _.Count = list.Count

    member _.Current =
        if current >= 0 && current < list.Count then
            list.[current]
        else
            String.Empty

    member _.Clear() =
        list.Clear()
        current <- -1

    member _.Add (line: string MaybeNull) = 
        match line with 
        | Null
        | "" -> ()
        | NonNull line -> list.Add(line)

    member _.AddLast (line: string MaybeNull) =  
        match line with 
        | Null
        | "" -> ()
        | NonNull line ->
            list.Add(line)
            current <- list.Count

    member x.Previous() =
        if (list.Count > 0) then
            current <- ((current - 1) + list.Count) % list.Count

        x.Current

    member x.Next() =
        if (list.Count > 0) then
            current <- (current + 1) % list.Count

        x.Current

/// List of available optionsCache
type internal Options() =
    inherit History()

    let mutable root = ""

    member _.Root
        with get () = root
        and set (v) = (root <- v)

/// Cursor position management

module internal Utils =

    let guard (f) =
        try
            f ()
        with e ->
            warning (
                Failure(
                    sprintf
                        "Note: an unexpected exception in fsi.exe readline console support. Consider starting fsi.exe with the --no-readline option and report the stack trace below to the .NET or Mono implementors\n%s\n%s\n"
                        e.Message
                        e.StackTrace
                )
            )

    let rec previousWordFromIdx (line: string) (idx, isInWord) =
        if idx < 0 then
            0
        else
            match line.Chars(idx), isInWord with
            | ' ', true -> idx + 1
            | ' ', false -> previousWordFromIdx line (idx - 1, false)
            | _, _ -> previousWordFromIdx line (idx - 1, true)

    let rec nextWordFromIdx (line: string) (idx, isInWord) =
        if idx >= line.Length then
            line.Length - 1
        else
            match line.Chars(idx), isInWord with
            | ' ', true -> idx
            | ' ', false -> nextWordFromIdx line (idx + 1, false)
            | _, _ -> nextWordFromIdx line (idx + 1, true)

    /// An array stores ranges of full-width chars.
    ///
    /// The ranges are sorted by increasing order in the array, and each range are stored in the 2nth and 2n+1th
    /// position in the array (n is the ordinal number of the range)
    ///
    /// Array [| a; b; c; d |] represents range [a, b] or [c, d], means chars in these ranges are full-width.
    ///
    /// Definition: https://www.unicode.org/reports/tr11/
    ///
    /// Data source: https://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt
    let private fullWidthCharRanges =
        Array.concat
            [|
                [| '\u1100'; '\u115f' |]
                [| '\u231a'; '\u231b' |]
                [| '\u2329'; '\u232a' |]
                [| '\u23e9'; '\u23ec' |]
                [| '\u23f0'; '\u23f0' |]
                [| '\u23f3'; '\u23f3' |]
                [| '\u25fd'; '\u25fe' |]
                [| '\u2614'; '\u2615' |]
                [| '\u2648'; '\u2653' |]
                [| '\u267f'; '\u267f' |]
                [| '\u2693'; '\u2693' |]
                [| '\u26a1'; '\u26a1' |]
                [| '\u26aa'; '\u26ab' |]
                [| '\u26bd'; '\u26be' |]
                [| '\u26c4'; '\u26c5' |]
                [| '\u26ce'; '\u26ce' |]
                [| '\u26d4'; '\u26d4' |]
                [| '\u26ea'; '\u26ea' |]
                [| '\u26f2'; '\u26f3' |]
                [| '\u26f5'; '\u26f5' |]
                [| '\u26fa'; '\u26fa' |]
                [| '\u26fd'; '\u26fd' |]
                [| '\u2705'; '\u2705' |]
                [| '\u270a'; '\u270b' |]
                [| '\u2728'; '\u2728' |]
                [| '\u274c'; '\u274c' |]
                [| '\u274e'; '\u274e' |]
                [| '\u2753'; '\u2755' |]
                [| '\u2757'; '\u2757' |]
                [| '\u2795'; '\u2797' |]
                [| '\u27b0'; '\u27b0' |]
                [| '\u27bf'; '\u27bf' |]
                [| '\u2b1b'; '\u2b1c' |]
                [| '\u2b50'; '\u2b50' |]
                [| '\u2b55'; '\u2b55' |]
                [| '\u2e80'; '\u303e' |]
                [| '\u3041'; '\u3096' |]
                [| '\u3099'; '\u30ff' |]
                [| '\u3105'; '\u312f' |]
                [| '\u3131'; '\u318e' |]
                [| '\u3190'; '\u3247' |]
                [| '\u3250'; '\u4dbf' |]
                [| '\u4e00'; '\ua4c6' |]
                [| '\ua960'; '\ua97c' |]
                [| '\uac00'; '\ud7a3' |]
                [| '\uf900'; '\ufaff' |]
                [| '\ufe10'; '\ufe1f' |]
                [| '\ufe30'; '\ufe6b' |]
                [| '\uff01'; '\uff60' |]
                [| '\uffe0'; '\uffe6' |]
            |]

    let isFullWidth (char) =
        // for array [| a; b; c; d |],
        // if a value is in (a, b) or (c, d), the result of Array.BinarySearch will be a negative even number
        // if a value is a, b, c, d, the result will be a positive number
        let n = Array.BinarySearch(fullWidthCharRanges, char)
        n >= 0 || n % 2 = 0

    /// Limits BufferWidth to make sure that full-width characters will not be print to wrong position.
    ///
    /// The return value is Console.BufferWidth - 2.
    ///
    /// When printing full-width characters to the screen (such as 一二三四五六七八九零),
    ///
    /// if BufferWidth = Console.BufferWidth, the output will be
    ///
    /// #> 一二三四五六七八九零一二三四五六七八九  # (零 is missing)
    ///
    /// #一二三四五六七八九零                      #
    ///
    /// if BufferWidth = Console.BufferWidth - 1, the output will be
    ///
    /// #> 一二三四五六七八九零一二三四五六七八九零# (零 is printed, but will not correctly calculate cursor position)
    ///
    /// #一二三四五六七八九零                      # (cursor may appear in the middle of the character)
    ///
    /// if BufferWidth = Console.BufferWidth - 2, the output will be
    ///
    /// #> 一二三四五六七八九零一二三四五六七八九  #
    ///
    /// #零一二三四五六七八九零                    # (work correctly)
    let bufferWidth () = Console.BufferWidth - 2

[<Sealed>]
type internal Cursor =
    static member ResetTo(top, left) =
        Utils.guard (fun () ->
            Console.CursorTop <- min top (Console.BufferHeight - 1)
            Console.CursorLeft <- left)

    static member Move(delta) =
        let width = Utils.bufferWidth ()
        let position = Console.CursorTop * width + Console.CursorLeft + delta

        let top = position / width
        let left = position % width
        Cursor.ResetTo(top, left)

type internal Anchor =
    {
        top: int
        left: int
    }

    static member Current(inset) =
        {
            top = Console.CursorTop
            left = max inset Console.CursorLeft
        }

    static member Top(inset) = { top = 0; left = inset }

    member p.PlaceAt(inset, index) =
        //printf "p.top = %d, p.left = %d, inset = %d, index = %d\n" p.top p.left inset index
        let width = Utils.bufferWidth ()
        let index = inset + index

        let left = index % width
        let top = p.top + index / width
        Cursor.ResetTo(top, left)

type internal ReadLineConsole() =
    let history = new History()

    static let supportsBufferHeightChange = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)

    let mutable complete: (string option * string -> seq<string>) =
        fun (_s1, _s2) -> Seq.empty

    member _.SetCompletionFunction f = complete <- f

    /// Inset all inputs by this amount
    member _.Prompt = "> "

    member _.Prompt2 = "- "

    member x.Inset = x.Prompt.Length

    member _.GetOptions(input: string) =
        /// Tab optionsCache available in current context
        let optionsCache = new Options()

        let rec look parenCount i =
            if i <= 0 then
                i
            else
                match input.Chars(i - 1) with
                | c when Char.IsLetterOrDigit(c) (* or Char.IsWhiteSpace(c) *) -> look parenCount (i - 1)
                | '.'
                | '_' -> look parenCount (i - 1)
                | '}'
                | ')'
                | ']' -> look (parenCount + 1) (i - 1)
                | '('
                | '{'
                | '[' -> look (parenCount - 1) (i - 1)
                | _ when parenCount > 0 -> look parenCount (i - 1)
                | _ -> i

        let start = look 0 input.Length

        let name = input.Substring(start, input.Length - start)

        if name.Trim().Length > 0 then
            let lastDot = name.LastIndexOf('.')

            let attr, pref, root =
                if (lastDot < 0) then
                    None, name, input.Substring(0, start)
                else
                    Some(name.Substring(0, lastDot)), name.Substring(lastDot + 1), input.Substring(0, start + lastDot + 1)

            try
                complete (attr, pref)
                |> Seq.filter (fun option -> option.StartsWith(pref, StringComparison.Ordinal))
                |> Seq.iter (fun option -> optionsCache.Add(option))

                optionsCache.Root <- root
            with _ ->
                optionsCache.Clear()

            optionsCache, true
        else
            optionsCache, false

    member _.MapCharacter(c) : string =
        match c with
        | '\x1A' -> "^Z"
        | _ -> "^?"

    member x.GetCharacterSize(c) =
        if Char.IsControl(c) then x.MapCharacter(c).Length
        elif Utils.isFullWidth c then 2
        else 1

    static member TabSize = 4

    member x.ReadLine() =

        let checkLeftEdge prompt =
            let currLeft = Console.CursorLeft

            if currLeft < x.Inset then
                if currLeft = 0 then
                    Console.Write(if prompt then x.Prompt2 else String(' ', x.Inset))

                Utils.guard (fun () ->
                    Console.CursorTop <- min Console.CursorTop (Console.BufferHeight - 1)
                    Console.CursorLeft <- x.Inset)

        // The caller writes the primary prompt.  If we are reading the 2nd and subsequent lines of the
        // input we're responsible for writing the secondary prompt.
        checkLeftEdge true

        /// Cursor anchor - position of !anchor when the routine was called
        let mutable anchor = Anchor.Current x.Inset

        /// Length of the output currently rendered on screen.
        let mutable rendered = 0

        /// Input has changed, therefore options cache is invalidated.
        let mutable changed = false

        /// Cache of optionsCache
        let mutable optionsCache = Options()

        let moveCursorToNextLine c =
            let charSize = x.GetCharacterSize(c)

            if Console.CursorLeft + charSize > Utils.bufferWidth () then
                if Console.CursorTop + 1 = Console.BufferHeight then
                    if supportsBufferHeightChange then
                        Console.BufferHeight <- Console.BufferHeight + 1
                    else
                        Console.WriteLine()
                        anchor <- { anchor with top = (anchor).top - 1 }

                Cursor.Move(0)

        let writeBlank () =
            moveCursorToNextLine (' ')
            Console.Write(' ')

        let writeChar (c) =
            moveCursorToNextLine (c)

            if Char.IsControl(c) then
                let s = x.MapCharacter c
                Console.Write(s)
                rendered <- rendered + s.Length
            else
                Console.Write(c)
                rendered <- rendered + x.GetCharacterSize(c)

        /// The console input buffer.
        let input = new StringBuilder()

        /// Current position - index into the input buffer
        let mutable current = 0

        let render () =
            let curr = current
            anchor.PlaceAt(x.Inset, 0)

            let rec getLineWidth state i =
                if i = curr || i = input.Length then
                    state
                else
                    getLineWidth (state + x.GetCharacterSize(input.Chars i)) (i + 1)

            let position = getLineWidth 0 0

            // render the current text, computing a new value for "rendered"
            let old_rendered = rendered
            rendered <- 0

            for i = 0 to input.Length - 1 do
                writeChar (input.Chars(i))

            // blank out any dangling old text
            for i = rendered to old_rendered - 1 do
                writeBlank ()

            anchor.PlaceAt(x.Inset, position)

        render ()

        let insertChar (c: char) =
            if current = input.Length then
                current <- current + 1
                input.Append(c) |> ignore
                writeChar (c)
            else
                input.Insert(current, c) |> ignore
                current <- current + 1
                render ()

        let insertTab () =
            for i = ReadLineConsole.TabSize - (current % ReadLineConsole.TabSize) downto 1 do
                insertChar (' ')

        let moveLeft () =
            if current > 0 && (current - 1 < input.Length) then
                current <- current - 1
                let c = input.Chars(current)
                Cursor.Move(-x.GetCharacterSize c)

        let moveRight () =
            if current < input.Length then
                let c = input.Chars(current)
                current <- current + 1
                Cursor.Move(x.GetCharacterSize c)

        let moveWordLeft () =
            if current > 0 && (current - 1 < input.Length) then
                let line = input.ToString()
                current <- Utils.previousWordFromIdx line (current - 1, false)
                anchor.PlaceAt(x.Inset, current)

        let moveWordRight () =
            if current < input.Length then
                let line = input.ToString()
                let idxToMoveTo = Utils.nextWordFromIdx line (current + 1, false)

                // if has reached end of the last word
                if idxToMoveTo = current && current < line.Length then
                    current <- line.Length
                else
                    current <- idxToMoveTo

                anchor.PlaceAt(x.Inset, current)

        let setInput (line: string) =
            input.Length <- 0
            input.Append(line) |> ignore
            current <- input.Length
            render ()

        let tabPress (shift) =
            let opts, prefix =
                if changed then
                    changed <- false
                    x.GetOptions(input.ToString())
                else
                    optionsCache, false

            optionsCache <- opts

            if (opts.Count > 0) then
                let part = if shift then opts.Previous() else opts.Next()

                setInput (opts.Root + part)
            else if (prefix) then
                Console.Beep()
            else
                insertTab ()

        let delete () =
            if (input.Length > 0 && current < input.Length) then
                input.Remove(current, 1) |> ignore
                render ()

        let deleteFromStartOfLineToCursor () =
            if (input.Length > 0 && current > 0) then
                input.Remove(0, current) |> ignore
                current <- 0
                render ()

        let deleteWordLeadingToCursor () =
            if (input.Length > 0 && current > 0) then
                let line = input.ToString()
                let idx = Utils.previousWordFromIdx line (current - 1, false)
                input.Remove(idx, current - idx) |> ignore
                current <- idx
                render ()

        let deleteToEndOfLine () =
            if (current < input.Length) then
                input.Remove(current, input.Length - current) |> ignore
                render ()

        let insert (key: ConsoleKeyInfo) =
            // REVIEW: is this F6 rewrite required? 0x1A looks like Ctrl-Z.
            // REVIEW: the Ctrl-Z code is not recognised as EOF by the lexer.
            // REVIEW: looks like a relic of the port of readline, which is currently removable.
            let c = if (key.Key = ConsoleKey.F6) then '\x1A' else key.KeyChar

            insertChar (c)

        let backspace () =
            if (input.Length > 0 && current > 0) then
                input.Remove(current - 1, 1) |> ignore
                current <- current - 1
                render ()

        let enter () =
            Console.Write("\n")
            let line = input.ToString()

            if (line = "\x1A") then
                null
            else
                if (line.Length > 0) then
                    history.AddLast(line)

                line

        let clear () =
            current <- input.Length

            let setPrompt prompt =
                if prompt then // We only allow clearing if prompt is ">"
                    Console.Clear()
                    Console.Write(x.Prompt)
                    Console.Write(input.ToString())
                    anchor <- Anchor.Top(x.Inset)

            let previous = history.Previous()
            history.Next() |> ignore

            if previous = "" then
                setPrompt true
            else
                setPrompt (previous.EndsWith(";;"))

        let home () =
            current <- 0
            anchor.PlaceAt(x.Inset, 0)

        let rec read () =
            let key = Console.ReadKey true

            match key.Key with
            | ConsoleKey.Backspace ->
                backspace ()
                change ()
            | ConsoleKey.Delete ->
                delete ()
                change ()
            | ConsoleKey.Enter -> enter ()
            | ConsoleKey.Tab ->
                tabPress (key.Modifiers &&& ConsoleModifiers.Shift <> enum 0)
                read ()
            | ConsoleKey.UpArrow ->
                setInput (history.Previous())
                change ()
            | ConsoleKey.DownArrow ->
                setInput (history.Next())
                change ()
            | ConsoleKey.RightArrow when key.Modifiers &&& ConsoleModifiers.Control = enum 0 ->
                moveRight ()
                change ()
            | ConsoleKey.LeftArrow when key.Modifiers &&& ConsoleModifiers.Control = enum 0 ->
                moveLeft ()
                change ()
            | ConsoleKey.Escape ->
                setInput String.Empty
                change ()
            | ConsoleKey.Home ->
                home ()
                change ()
            | ConsoleKey.End ->
                current <- input.Length
                anchor.PlaceAt(x.Inset, rendered)
                change ()
            | _ ->

                match key.Modifiers, key.Key with
                | ConsoleModifiers.Control, ConsoleKey.A ->
                    home ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.E ->
                    current <- input.Length
                    anchor.PlaceAt(x.Inset, rendered)
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.B ->
                    moveLeft ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.F ->
                    moveRight ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.LeftArrow
                | ConsoleModifiers.Alt, ConsoleKey.B ->
                    moveWordLeft ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.RightArrow
                | ConsoleModifiers.Alt, ConsoleKey.F ->
                    moveWordRight ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.K ->
                    deleteToEndOfLine ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.P ->
                    setInput (history.Previous())
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.N ->
                    setInput (history.Next())
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.D ->
                    if (input.Length = 0) then
                        exit 0 //quit
                    else
                        delete ()
                        change ()
                | ConsoleModifiers.Control, ConsoleKey.L ->
                    clear ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.U ->
                    deleteFromStartOfLineToCursor ()
                    change ()
                | ConsoleModifiers.Control, ConsoleKey.W ->
                    deleteWordLeadingToCursor ()
                    change ()
                | _ ->
                    // Note: If KeyChar=0, the not a proper char, e.g. it could be part of a multi key-press character,
                    //       e.g. e-acute is ' and e with the French (Belgium) IME and US Intl KB.
                    // Here: skip KeyChar=0 (except for F6 which maps to 0x1A (ctrl-Z?)).
                    if key.KeyChar <> '\000' || key.Key = ConsoleKey.F6 then
                        insert (key)
                        change ()
                    else
                        // Skip and read again.
                        read ()

        and change () =
            changed <- true
            read ()

        read ()
