#!/usr/bin/env bash
#
# fff - fucking fast file-manager.

get_os() {
    # Figure out the current operating system to set some specific variables.
    # '$OSTYPE' typically stores the name of the OS kernel.
    case "$OSTYPE" in
        # Mac OS X / macOS.
        darwin*)
            opener="open"
            file_flags="bIL"
        ;;

        haiku)
            opener="open"
        ;;
    esac
}

setup_terminal() {
    # Setup the terminal for the TUI.
    # '\e[?1049h': Save current terminal screen.
    # '\e[?6h':    Restrict cursor to scrolling area.
    # '\e[?7l':    Disable line wrapping.
    # '\e[?25l':   Hide the cursor.
    # '\e[2J':     Clear the screen.
    # '\e[1;Nr':   Limit scrolling to scrolling area.
    #              Also sets cursor to (0,0).
    printf '\e[?1049h\e[?6h\e[?7l\e[?25l\e[2J\e[1;%sr' "$max_items"
}

reset_terminal() {
    # Reset the terminal to a useable state (undo all changes).
    # '\e[?6l':  Unrestrict cursor movement (full window).
    # '\e[?7h':  Re-enable line wrapping.
    # '\e[?25h': Unhide the cursor.
    # '\e[2J':   Clear the terminal.
    # '\e[;r':   Set the scroll region to its default value.
    #            Also sets cursor to (0,0).
    printf '\e[?6l\e[?7h\e[?25h\e[2J\e[;r'
}

clear_screen() {
    # Only clear the scrolling window (dir item list).
    # '\e[%s;%sH': Move cursor to bottom right corner of scroll area.
    # '\e[1J':     Clear screen to top left corner (from cursor up).
    # '\e[1;%sr':  Clearing the screen resets the scroll region(?). Re-set it.
    #              Also sets cursor to (0,0).
    printf '\e[%s;%sH\e[1J\e[1;%sr' "$((max_items+1))" "$COLUMNS" "$max_items"
}

get_term_size() {
    # Get terminal size ('stty' is POSIX and always available).
    # This can't be done reliably across all bash versions in pure bash.
    read -r LINES COLUMNS < <(stty size)

    # Max list items that fit in the scroll area.
    ((max_items=LINES-3))
}

get_ls_colors() {
    # Parse the LS_COLORS variable and source each file type
    # as a separate variable.
    # Format: ':.ext=0;0:*.jpg=0;0;0:*png=0;0;0;0:'
    [[ -z $LS_COLORS ]] && {
        FFF_LS_COLORS=0
        return
    }

    # Turn $LS_COLORS into an array.
    IFS=: read -ra ls_cols <<< "$LS_COLORS"

    for ((i=0;i<${#ls_cols[@]};i++)); {
        # Separate patterns from file types.
        [[ ${ls_cols[i]} =~ ^\*[^\.] ]] &&
            ls_patterns+="${ls_cols[i]/=*}|"

        # Prepend 'ls_' to all LS_COLORS items
        # if they aren't types of files (symbolic links, block files etc.)
        [[ ${ls_cols[i]} =~ ^(\*|\.) ]] && {
            ls_cols[i]="${ls_cols[i]#\*}"
            ls_cols[i]="ls_${ls_cols[i]#.}"
        }

        ls_cols[i]="${ls_cols[i]//;/\\;};"
   }

    # Strip non-ascii characters from the string as they're
    # used as a key to color the dir items and variable
    # names in bash must be '[a-zA-z0-9_]'.
    ls_exts="${ls_cols[*]//[^a-zA-Z0-9=\\;]/_}"

    # Store the patterns in a '|' separated string
    # for use in a REGEX match later.
    ls_patterns="${ls_patterns//\*}"
    ls_patterns="${ls_patterns%?}"

    # bash 3 compatible method of sourcing a variable.
    # see: https://i.imgur.com/e4tIACE.jpg
    # shellcheck source=/dev/null
    source /dev/stdin <<< "$ls_exts" >/dev/null 2>&1
}

status_line() {
    # Status_line to print when files are marked for operation.
    local mark_ui="[${#marked_files[@]}] selected (${file_program[*]}) [p] ->"

    # Escape the directory string before printing it.
    escape_dir "${PWD:-/}"

    # '\e7':       Save cursor position.
    #              This is more widely supported than '\e[s'.
    # '\e[?6l':    Unrestrict cursor movement (full window).
    # '\e[%sH':    Move cursor to bottom of the terminal.
    # '\e[30;41m': Set foreground and background colors.
    # '\e[K':      Clear to end of line (set background color to whole line).
    # '\e[m':      Reset text formatting.
    # '\n\e[K':    Also clear the line below the status_line.
    # '\e[?6h':    Restrict cursor to scrolling area.
    # '\e8':       Restore cursor position.
    #              This is more widely supported than '\e[u'.
    printf '\e7\e[?6l\e[%sH\e[30;4%sm%s %s%s\e[K\e[m\n\e[K\e[?6h\e8' \
           "$((LINES-1))" \
           "${FFF_COL2:-1}" \
           "($((scroll+1))/$((list_total+1)))" \
           "${marked_files[*]:+${mark_ui}}" \
           "$escaped_dir"
}

read_dir() {
    # Read a directory to an array and sort it directories first.
    local dirs=()
    local files=()
    local item_index

    for item in "$PWD"/*; do
        if [[ -d $item ]]; then
            dirs+=("$item")
            ((item_index++))

            # Find the position of the child directory in the
            # parent directory list.
            [[ $item == "$previous_dir" ]] &&
                ((previous_index=item_index))
        else
            files+=("$item")
        fi
    done

    list=("${dirs[@]}" "${files[@]}")

    # Indicate that the directory is empty.
    [[ -z ${list[0]} ]] &&
        list[0]="empty"

    ((list_total=${#list[@]}-1))

    # Save the original dir in a second list as a backup.
    cur_list=("${list[@]}")
}

escape_dir() {
    # Escape a directory string.
    # '%q' prints strings literally which is what I always.
    # assumed '%s' did.
    printf -v escaped_dir '%q' "$1"

    # Fix directories with spaces.
    escaped_dir="${escaped_dir//\\ / }"
}

print_line() {
    # Format the list item and print it.
    local file_name="${list[$1]##*/}"
    local file_ext="${file_name##*.}"
    local format
    local suffix

    # If the dir item doesn't exist, end here.
    if [[ ! -e ${list[$1]} ]]; then
        return

    # Directory.
    elif [[ -d ${list[$1]} ]]; then
        format+="\\e[${di:-1;3${FFF_COL1:-2}}m"
        suffix+='/'

    # Block special file.
    elif [[ -b ${list[$1]} ]]; then
        format+="\\e[${bd:-40;33;01}m"

    # Character special file.
    elif [[ -c ${list[$1]} ]]; then
        format+="\\e[${cd:-40;33;01}m"

    # Executable file.
    elif [[ -x ${list[$1]} ]]; then
        format+="\\e[${ex:-01;32}m"

    # Symbolic Link.
    elif [[ -h ${list[$1]} ]]; then
        format+="\\e[${ln:-01;36}m"

    # Fifo file.
    elif [[ -p ${list[$1]} ]]; then
        format+="\\e[${pi:-40;33}m"

    # Socket file.
    elif [[ -S ${list[$1]} ]]; then
        format+="\\e[${so:-01;35}m"

    # Color files that end in a pattern as defined in LS_COLORS.
    # 'BASH_REMATCH' is an array that stores each REGEX match.
    elif [[ $FFF_LS_COLORS == 1 &&
            $ls_patterns &&
            $file_name =~ ($ls_patterns)$ ]]; then
        match="${BASH_REMATCH[0]}"
        file_ext="ls_${match//[^a-zA-Z0-9=\\;]/_}"
        format+="\\e[${!file_ext:-${fi:-37}}m"

    # Color files based on file extension and LS_COLORS.
    # Check if file extension adheres to POSIX naming
    # stardard before checking if it's a variable.
    elif [[ $FFF_LS_COLORS == 1 &&
            $file_ext != "$file_name" &&
            $file_ext =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
        file_ext="ls_${file_ext}"
        format+="\\e[${!file_ext:-${fi:-37}}m"

    else
        format+="\\e[${fi:-37}m"
    fi

    # If the list item is under the cursor.
    (($1 == scroll)) &&
        format+="\\e[1;3${FFF_COL4:-6};7m"

    # If the list item is marked for operation.
    [[ ${marked_files[$1]} == "${list[$1]:-null}" ]] && {
        format+="\\e[3${FFF_COL3:-1}m "
        suffix+='*'
    }

    # Escape the directory string.
    escape_dir "$file_name"

    printf '\r%b%s\e[m\r' "$format" "${escaped_dir}${suffix}"
}

draw_dir() {
    # Print the max directory items that fit in the scroll area.
    local scroll_start="$scroll"
    local scroll_new_pos
    local scroll_end

    # When going up the directory tree, place the cursor on the position
    # of the previous directory.
    ((find_previous == 1)) && {
        ((scroll_start=previous_index-1))
        ((scroll=scroll_start))

        # Clear the directory history. We're here now.
        find_previous=
    }

    # If current dir is near the top of the list, keep scroll position.
    if ((list_total < max_items || scroll < max_items/2)); then
        ((scroll_start=0))
        ((scroll_end=max_items))
        ((scroll_new_pos=scroll + 1))

    # If curent dir is near the end of the list, keep scroll position.
    elif ((list_total - scroll < max_items/2)); then
        ((scroll_start=list_total - max_items + 1))
        ((scroll_new_pos=max_items - (list_total-scroll)))
        ((scroll_end=list_total+1))

    # If current dir is somewhere in the middle, center scroll position.
    else
        ((scroll_start=scroll-max_items/2))
        ((scroll_end=scroll_start+max_items))
        ((scroll_new_pos=max_items/2+1))
    fi

    for ((i=scroll_start;i<scroll_end;i++)); {
        # Don't print one too many newlines.
        ((i > scroll_start)) &&
            printf '\n'

        print_line "$i"
    }

    # Move the cursor to its new position if it changed.
    # If the variable 'scroll_new_pos' is empty, the cursor
    # is moved to line '0'.
    printf '\e[%sH' "$scroll_new_pos"
    ((y=scroll_new_pos))
}

redraw() {
    # Redraw the current window.
    # If 'full' is passed, re-fetch the directory list.
    [[ $1 == full ]] && {
        read_dir
        scroll=0
    }

    clear_screen
    draw_dir
    status_line
}

mark() {
    # Mark file for operation.
    # If an item is marked in a second directory,
    # clear the marked files.
    [[ $PWD != "$mark_dir" ]] &&
        marked_files=()

    if [[ ${marked_files[$1]} == "${list[$1]}" ]]; then
        unset 'marked_files[scroll]'

    else
        marked_files[$1]="${list[$1]}"
        mark_dir="$PWD"
    fi

    # Clear line before changing it.
    printf '\e[K'
    print_line "$1"

    # Find the program to use.
    case "$2" in
        y) file_program=(cp -R) ;;
        m) file_program=(mv) ;;

        # Trash is an 'fff' function.
        d) file_program=(trash) ;;
    esac

    status_line
}

trash() {
    # 'trash' a file.
    cmd_line "trash [${#marked_files[@]}] items? [y/n]: " y n

    # The last function argument is '.' (mv file dir file .).
    [[ $cmd_reply == y ]] && {
        cd "$FFF_TRASH" && mv "$@"

        # Go back to where we were.
        cd - ||:;
    }
}

open() {
    # Open directories and files.
    if [[ -d $1/ ]]; then
        search=
        previous_dir="$PWD"
        PWD="$1"
        redraw full

    elif [[ -f $1 ]]; then
        # Figure out what kind of file we're working with.
        mime_type="$(file "-${file_flags:-biL}" "$1")"

        # Open all text-based files in '$EDITOR'.
        # Everything else goes through 'xdg-open'/'open'.
        case "$mime_type" in
            text/*|*x-empty*|*json*)
                "${EDITOR:-vi}" "$1"

                # Re-set TUI settings to make sure '$EDITOR' doesn't
                # reset them to defaults.
                setup_terminal
                redraw
            ;;

            *)
                # 'nohup':  Make the process immune to hangups.
                # '&':      Send it to the background.
                # 'disown': Detach it from the shell.
                nohup "${FFF_OPENER:-${opener:-xdg-open}}" "$1" &>/dev/null &
                disown
            ;;
        esac
    fi
}

cmd_line() {
    # Write to the command_line (under status_line).
    cmd_reply=

    # '\e7':     Save cursor position.
    # '\e[?6l':  Unrestrict cursor movement.
    # '\e[?25h': Unhide the cursor.
    # '\e[%sH':  Move cursor to bottom (cmd_line).
    printf '\e7\e[?6l\e[%sH\e[?25h' "$LINES"

    # '\r\e[K': Redraw the read prompt on every keypress.
    #           This is mimicking what happens normally.
    while IFS= read -rsn 1 -p $'\r\e[K'"${1}${cmd_reply}" read_reply; do
        case "$read_reply" in
            # Backspace.
            $'\177'|$'\b')
                cmd_reply="${cmd_reply%?}"
            ;;

            # Escape / Custom 'no' value (used as a replacement for '-n 1').
            $'\e'|"${3:-null}")
                cmd_reply=
                break
            ;;

            # Enter/Return.
            "")
                break
            ;;

            # Custom 'yes' value (used as a replacement for '-n 1').
            "${2:-null}")
                cmd_reply="$read_reply"
                break
            ;;

            # Anything else, add it to read reply.
            " "|*)
                cmd_reply+="$read_reply"
            ;;
        esac

        # Search on keypress if search passed as an argument.
        [[ $2 == search ]] && {
            # '\e[?25h': Hide the cursor.
            printf '\e[?25l'

            # Use a greedy glob to search.
            list=("$PWD"/*"$cmd_reply"*)
            ((list_total=${#list[@]}-1))

            # Draw the search results on screen.
            scroll=0
            redraw

            # '\e[?6l':  Unrestrict cursor position.
            # '\e[?25h': Unhide the cursor.
            printf '\e[?6l\e[%sH\e[?25h' "$LINES"
        }
    done

    # '\e[2K':   Clear the entire cmd_line on finish.
    # '\e[?6h':  Restrict cursor position.
    # '\e[?25l': Hide the cursor.
    # '\e8':     Restore cursor position.
    printf '\e[2K\e[?6h\e[?25l\e8'
}

key() {
    case "$1" in
        # Open list item.
        # 'C' is what bash sees when the right arrow is pressed ('\e[C').
        # '' is what bash sees when the enter/return key is pressed.
        "${FFF_KEY_CHILD1:=l}"|\
        "${FFF_KEY_CHILD2:=C}"|\
        "${FFF_KEY_CHILD3:=""}")
            open "${list[scroll]}"
        ;;

        # Go to the parent directory.
        # 'D' is what bash sees when the left arrow is pressed ('\e[D').
        # '\177' and '\b' are what bash sometimes sees when the backspace
        # key is pressed.
        "${FFF_KEY_PARENT1:=h}"|\
        "${FFF_KEY_PARENT2:=D}"|\
        "${FFF_KEY_PARENT3:=$'\177'}"|\
        "${FFF_KEY_PARENT4:=$'\b'}")
            # If a search was done, clear the results and open the current dir.
            if ((search == 1)); then
                open "$PWD"

            # If '$PWD' is empty we're at '/', do nothing.
            elif [[ $PWD ]]; then
                find_previous=1
                open "${PWD%/*}"
            fi
        ;;

        # Scroll down.
        # 'B' is what bash sees when the down arrow is pressed ('\e[B').
        "${FFF_KEY_SCROLL_DOWN1:=j}"|\
        "${FFF_KEY_SCROLL_DOWN2:=B}")
            ((scroll < list_total)) && {
                ((scroll++))
                ((y < max_items )) && ((y++))

                print_line "$((scroll-1))"
                printf '\n'
                print_line "$scroll"
                status_line
            }
        ;;

        # Scroll up.
        # 'A' is what bash sees when the down arrow is pressed ('\e[A').
        "${FFF_KEY_SCROLL_UP1:=k}"|\
        "${FFF_KEY_SCROLL_UP2:=A}")
            # '\e[1L': Insert a line above the cursor.
            # '\e[A':  Move cursor up a line.
            ((scroll > 0)) && {
                ((scroll--))

                print_line "$((scroll+1))"

                if ((y < 2)); then
                    printf '\e[1L'
                else
                    printf '\e[A'
                    ((y--))
                fi

                print_line "$scroll"
                status_line

            }
        ;;

        # Go to top.
        "${FFF_KEY_TO_TOP:=g}")
            ((scroll != 0)) && {
                ((scroll=0))
                redraw
            }
        ;;

        # Go to bottom.
        "${FFF_KEY_TO_BOTTOM:=G}")
            ((scroll != list_total)) && {
                ((scroll=list_total))
                redraw
            }
        ;;

        # Show hidden files.
        "${FFF_KEY_HIDDEN:=.}")
            # 'a=a>0?0:++a': Toggle between both values of 'shopt_flags'.
            #                This also works for '3' or more values with
            #                some modification.
            shopt_flags=(u s)
            shopt -"${shopt_flags[((a=a>0?0:++a))]}" dotglob
            redraw full
        ;;

        # Search.
        "${FFF_KEY_SEARCH:=/}")
            cmd_line "/" "search"

            # If the search came up empty, redraw the current dir.
            if [[ -z ${list[*]} || -z $cmd_reply ]]; then
                list=("${cur_list[@]}")
                ((list_total=${#list[@]}-1))
                redraw
                search=
            else
                search=1
            fi
        ;;

        # Spawn a shell.
        "${FFF_KEY_SHELL:=s}")
            reset_terminal
            cd "$PWD" && "$SHELL"
            redraw
        ;;

        # Mark files for operation.
        "${FFF_KEY_YANK:=y}"|\
        "${FFF_KEY_MOVE:=m}"|\
        "${FFF_KEY_TRASH:=d}")
            mark "$scroll" "$1"
        ;;

        # Do the file operation.
        "${FFF_KEY_PASTE:=p}")
            [[ ${marked_files[*]} ]] && {
                cd "$PWD" && "${file_program[@]}" "${marked_files[@]}" .
                marked_files=()
                redraw full
            }
        ;;

        # Clear all marked files.
        "${FFF_KEY_CLEAR:=c}")
            marked_files=()
            redraw
        ;;

        # Rename list item.
        "${FFF_KEY_RENAME:=r}")
            cmd_line "rename ${list[scroll]##*/}: "

            [[ $cmd_reply ]] && {
                # Error handling.
                # 'mv' will nest the dir if it already exists.
                [[ -e ${PWD}/${cmd_reply} ]] &&
                    cmd_reply+="(copy)"

                mv "${list[scroll]}" "${PWD}/${cmd_reply}"
                redraw full
            }
        ;;

        # Create a directory.
        "${FFF_KEY_MKDIR:=n}")
            cmd_line "mkdir: "

            [[ $cmd_reply ]] && {
                mkdir -p "${PWD}/${cmd_reply}"
                redraw full
            }
        ;;

        # Create a file.
        "${FFF_KEY_MKFILE:=f}")
            cmd_line "mkfile: "

            [[ $cmd_reply ]] && {
                : > "${PWD}/${cmd_reply}"
                redraw full
            }
        ;;

        # Show file attributes.
        "${FFF_KEY_ATTRIBUTES:=x}")
            clear_screen
            stat "${list[scroll]}"
            read -rn 1
            redraw
        ;;

        # Go to '$HOME'.
        "${FFF_KEY_GO_HOME:=~}")
            open ~
        ;;

        # Go to trash.
        "${FFF_KEY_GO_TRASH:=t}")
            open "$FFF_TRASH"
        ;;

        # Go to previous dir.
        "${FFF_KEY_PREVIOUS:=-}")
            open "$previous_dir"
        ;;

        # Directory favourites.
        [1-9])
            favourite="FFF_FAV${1}"
            favourite="${!favourite}"

            [[ $favourite ]] &&
                open "$favourite"
        ;;

        # Quit and store current directory in a file for CD on exit.
        # Don't allow user to redefine 'q' so a bad keybinding doesn't
        # remove the option to quit.
        q)
            : "${FFF_CD_FILE:=${XDG_CACHE_HOME:=${HOME}/.cache}/fff/.fff_d}"
            printf '%s\n' "$PWD" > "$FFF_CD_FILE"
            exit
        ;;
    esac
}

main() {
    # Handle a directory as the first argument.
    # 'pushd' is a cheap way of finding the full path to a directory.
    # It updates the '$PWD' variable on successful execution.
    # It handles relative paths as well as '../../../'.
    #
    # '||:': Do nothing if 'pushd' fails. We don't care.
    pushd "$1" &>/dev/null ||:

    # Handle version as the first argument.
    # TODO: Add full argument passing if I decide to add '-h' etc.
    [[ $1 == -v ]] && {
        printf '%s\n' "fff 1.2"
        exit
    }

    # bash 5 and some versions of bash 4 don't allow SIGWINCH to interrupt
    # a 'read' command and instead wait for it to complete. In this case it
    # causes the window to not redraw on resize until the user has pressed
    # a key (causing the read to finish). This sets a read timeout on the
    # affected versions of bash.
    # NOTE: This shouldn't affect idle performance as the loop doesn't do
    # anything until a key is pressed.
    # SEE: https://github.com/dylanaraps/fff/issues/48
    ((BASH_VERSINFO[0] > 3)) &&
        read_flags=(-t 0.05)

    # Initialize LS_COLORS support if enabled.
    ((${FFF_LS_COLORS:=1} == 1)) &&
        get_ls_colors

    get_os
    get_term_size
    setup_terminal

    # Set some bash options.
    # 'nocaseglob': Glob case insensitively (Used for case insensitive search).
    # 'nullglob':   Don't expand non-matching globs to themselves.
    shopt -s nocaseglob nullglob

    # Create the trash directory if it doesn't exist.
    # Better to get this done early.
    mkdir -p "${FFF_TRASH:=${XDG_CACHE_HOME:=${HOME}/.cache}/fff/trash}"

    # Trap the exit signal (we need to reset the terminal to a useable state.)
    # '\e[?1049l': Restore saved terminal screen.
    trap 'reset_terminal; printf "\e[?1049l"' EXIT

    # Trap 'Ctrl+c' and exit by mimicking the 'q' keypress.
    # This allow us to run the same plumbing on exit for both.
    trap 'key q' INT

    # Trap the window resize signal (handle window resize events).
    trap 'get_term_size; redraw' WINCH

    redraw full

    # Vintage infinite loop.
    for ((;;)); {
        read "${read_flags[@]}" -srn 1 && key "$REPLY"
    }
}

main "$@"
