#!/bin/sh
#
# fname - find files or directories by name
#
# Default excluded directories:
#   tmp log data .github
#
# Usage:
#   fname [options] [directory] [name-pattern]
#

helpFile="$HOME/lbin/fname.txt"

# ----------------------------------------------------------------------
# Defaults
# ----------------------------------------------------------------------

fileType="f"
allTypes=0
longListing=0
exactMatch=0
noExt=0
jsonOut=0
sortMode=""
verbose=1
extFilter=""

# Directories excluded by default.
#
# This is a space-separated list of directory names.
# Use -x to replace this list.
#
excludeDir="tmp log data .github"

# ----------------------------------------------------------------------
# Help
# ----------------------------------------------------------------------

show_help() {
    err_msg=$1

    if [ -n "$err_msg" ]; then
        printf 'Error: %s\n\n' "$err_msg" >&2
    fi

    if [ -f "$helpFile" ]; then
        cat "$helpFile"
    else
        cat <<EOF
Usage:
  fname [options] [directory] [name-pattern]

Arguments:
  directory      Directory to search from (default: current directory).
  name-pattern   Name or substring to match
                 (case-insensitive by default).

Type selection:
  -f             Files only (default).
  -d             Directories only.
  -a             Both files and directories.

Name matching:
  -X             Exact name match.
  (default)      Substring match (*pattern*), case-insensitive.

Extension filter (files only):
  -e EXT         Only files with extension EXT
                 (e.g. -e txt -> *.txt).

Other filters and format:
  -n             Files with no extension
                 (ignores -e and name-pattern).
  -l             Long listing (ls -l style).
  -j             JSON output (array of paths).

Excluded directories:
  -x "DIRS"      Space-separated directory names to exclude.
                 This replaces the default exclusion list.

Default exclusions:
  tmp log data .github

Sorting:
  -s mode        newest | oldest | size | name

Verbosity:
  -q             Quiet: only matching paths (and JSON if -j).
  -v             Verbose: show search description and summary
                 (default).

Examples:
  fname src controller
  fname -d . config
  fname -n /tmp
  fname -e php src user
  fname -e txt .
  fname -s newest -l . txt

  fname -x "tmp github data" .
  fname -x "tmp github data" . controller
  fname -x "" . controller

Notes:
  -x replaces the default exclusions.
  Use -x "" to search all directories.

EOF
    fi

    if [ -n "$err_msg" ]; then
        printf '\nError: %s\n' "$err_msg" >&2
        exit 1
    fi

    exit 0
}

error() {
    show_help "$1"
}

# ----------------------------------------------------------------------
# No arguments
# ----------------------------------------------------------------------

if [ "$#" -eq 0 ]; then
    show_help ""
fi

# ----------------------------------------------------------------------
# OS-specific stat commands
# ----------------------------------------------------------------------

case "$(uname 2>/dev/null)" in
    Darwin)
        STAT_MTIME='stat -f %m'
        STAT_SIZE='stat -f %z'
        ;;
    Linux)
        STAT_MTIME='stat -c %Y'
        STAT_SIZE='stat -c %s'
        ;;
    *)
        error "Unsupported OS: $(uname 2>/dev/null)"
        ;;
esac

# ----------------------------------------------------------------------
# Options
#
# IMPORTANT:
#   -x = excluded directories
#   -X = exact name match
# ----------------------------------------------------------------------

while getopts "hdfalnjqs:ve:x:X" opt; do
    case "$opt" in
        h)
            show_help ""
            ;;
        d)
            fileType="d"
            ;;
        f)
            fileType="f"
            ;;
        a)
            allTypes=1
            ;;
        l)
            longListing=1
            ;;
        n)
            noExt=1
            ;;
        j)
            jsonOut=1
            ;;
        s)
            sortMode=$OPTARG
            ;;
        q)
            verbose=0
            ;;
        v)
            verbose=1
            ;;
        e)
            extFilter=$OPTARG
            ;;
        x)
            excludeDir=$OPTARG
            ;;
        X)
            exactMatch=1
            ;;
        \?)
            error "Invalid option"
            ;;
    esac
done

shift $((OPTIND - 1))

# ----------------------------------------------------------------------
# Positionals
# ----------------------------------------------------------------------

dirName="."
fileName=""

if [ $# -eq 1 ]; then
    if [ -d "$1" ]; then
        dirName=$1
    else
        fileName=$1
    fi
elif [ $# -ge 2 ]; then
    dirName=$1
    fileName=$2
fi

if [ ! -d "$dirName" ]; then
    error "Directory not found: $dirName"
fi

# ----------------------------------------------------------------------
# Validate sort mode
# ----------------------------------------------------------------------

case "$sortMode" in
    newest)
        search_sort="sorted by newest modified first"
        ;;
    oldest)
        search_sort="sorted by oldest modified first"
        ;;
    size)
        search_sort="sorted by size (largest first)"
        ;;
    name)
        search_sort="sorted alphabetically by name"
        ;;
    "")
        search_sort="unsorted (find order)"
        ;;
    *)
        error "Unknown sort mode: $sortMode"
        ;;
esac

# ----------------------------------------------------------------------
# Build human-readable description
# ----------------------------------------------------------------------

search_desc_type="files"

if [ "$fileType" = "d" ]; then
    search_desc_type="directories"
fi

if [ "$allTypes" -eq 1 ]; then
    search_desc_type="files and directories"
fi

if [ "$noExt" -eq 1 ]; then
    search_pattern="with no extension"
else
    if [ "$exactMatch" -eq 1 ]; then
        if [ -n "$fileName" ]; then
            search_pattern="named exactly '$fileName'"
        else
            search_pattern="with any name (exact mode, no name given)"
        fi
    else
        if [ -n "$fileName" ]; then
            search_pattern="with names containing '$fileName'"
        else
            search_pattern="with any name"
        fi
    fi
fi

if [ -n "$extFilter" ] && [ "$noExt" -ne 1 ]; then
    search_pattern="$search_pattern and extension '.$extFilter'"
fi

if [ -n "$excludeDir" ]; then
    search_exclusion="excluding directories: $excludeDir"
else
    search_exclusion="no excluded directories"
fi

# ----------------------------------------------------------------------
# Verbose search description
# ----------------------------------------------------------------------

if [ "$verbose" -eq 1 ]; then
    printf 'Searching "%s" for %s %s, %s.\n' \
        "$dirName" \
        "$search_desc_type" \
        "$search_pattern" \
        "$search_sort" >&2

    printf 'Exclusions: %s\n' "$search_exclusion" >&2
fi

# ----------------------------------------------------------------------
# Build exclusion expression
#
# Each directory name becomes:
#
#   ! -path "*/DIR" ! -path "*/DIR/*"
#
# This prevents descending into the directory.
#
# The expression is constructed as a shell string because the number
# of exclusions is variable.
# ----------------------------------------------------------------------

build_exclusions() {
    exclusions=""

    for excluded in $excludeDir; do
        [ -z "$excluded" ] && continue

        exclusions="$exclusions ! -path */$excluded"
        exclusions="$exclusions ! -path */$excluded/*"
    done

    printf '%s' "$exclusions"
}

find_exclusions=$(build_exclusions)

# ----------------------------------------------------------------------
# Build name pattern
# ----------------------------------------------------------------------

if [ "$exactMatch" -eq 1 ]; then
    pattern=${fileName:-*}
else
    if [ -n "$fileName" ]; then
        pattern="*${fileName}*"
    else
        pattern="*"
    fi
fi

# ----------------------------------------------------------------------
# Extension pattern
# ----------------------------------------------------------------------

extGlob=""

if [ -n "$extFilter" ]; then
    extGlob="*.${extFilter}"
fi

# ----------------------------------------------------------------------
# Find
# ----------------------------------------------------------------------
#
# Note:
#   The exclusion expression is inserted before the type/name tests.
#   This prevents find from descending into excluded directories.
#
#   We use eval here because POSIX sh does not have arrays and the
#   number of exclusion expressions is dynamic.
# ----------------------------------------------------------------------

if [ "$noExt" -eq 1 ]; then

    eval "nl_paths=\$(find \"\$dirName\" $find_exclusions \
        -type f ! -name '*.*' -print 2>/dev/null)"

else

    if [ "$allTypes" -eq 1 ]; then

        if [ -n "$extGlob" ]; then

            eval "nl_paths=\$(find \"\$dirName\" $find_exclusions \
                \( \
                    -type f -iname \"$pattern\" -iname \"$extGlob\" \
                    -o \
                    -type d -iname \"$pattern\" \
                \) -print 2>/dev/null)"

        else

            eval "nl_paths=\$(find \"\$dirName\" $find_exclusions \
                \( -type f -o -type d \) \
                -iname \"$pattern\" \
                -print 2>/dev/null)"

        fi

    elif [ "$fileType" = "d" ]; then

        eval "nl_paths=\$(find \"\$dirName\" $find_exclusions \
            -type d -iname \"$pattern\" \
            -print 2>/dev/null)"

    else

        if [ -n "$extGlob" ]; then

            eval "nl_paths=\$(find \"\$dirName\" $find_exclusions \
                -type f \
                -iname \"$pattern\" \
                -iname \"$extGlob\" \
                -print 2>/dev/null)"

        else

            eval "nl_paths=\$(find \"\$dirName\" $find_exclusions \
                -type f \
                -iname \"$pattern\" \
                -print 2>/dev/null)"

        fi
    fi
fi

# ----------------------------------------------------------------------
# Count
# ----------------------------------------------------------------------

result_count=0

if [ -n "$nl_paths" ]; then
    result_count=$(printf '%s\n' "$nl_paths" |
        wc -l |
        tr -d ' ')
fi

# ----------------------------------------------------------------------
# No results
# ----------------------------------------------------------------------

if [ "$result_count" -eq 0 ]; then
    if [ "$verbose" -eq 1 ]; then
        printf 'No matches found in "%s" for %s %s, %s.\n' \
            "$dirName" \
            "$search_desc_type" \
            "$search_pattern" \
            "$search_sort" >&2
    fi
    exit 0
fi

# ----------------------------------------------------------------------
# Sorting
# ----------------------------------------------------------------------

if [ -n "$sortMode" ]; then

    case "$sortMode" in

        newest|oldest)

            nl_paths=$(
                printf '%s\n' "$nl_paths" |
                awk -v cmd="$STAT_MTIME" '
                    {
                        c = cmd " \"" $0 "\""
                        if ((c | getline m) > 0) {
                            close(c)

                            if (m ~ /^[0-9]+$/)
                                print m "\t" $0
                        }
                    }
                ' |
                {
                    if [ "$sortMode" = "newest" ]; then
                        sort -rn
                    else
                        sort -n
                    fi
                } |
                cut -f2
            )

            ;;

        size)

            nl_paths=$(
                printf '%s\n' "$nl_paths" |
                awk -v cmd="$STAT_SIZE" '
                    {
                        c = cmd " \"" $0 "\""
                        if ((c | getline sz) > 0) {
                            close(c)

                            if (sz ~ /^[0-9]+$/)
                                print sz "\t" $0
                        }
                    }
                ' |
                sort -rn |
                cut -f2
            )

            ;;

        name)

            nl_paths=$(
                printf '%s\n' "$nl_paths" |
                sort
            )

            ;;

    esac
fi

# ----------------------------------------------------------------------
# Output
# ----------------------------------------------------------------------

if [ "$jsonOut" -eq 1 ]; then

    printf '%s\n' "$nl_paths" |
    awk '
        BEGIN {
            print "["
            sep = ""
        }

        {
            gsub(/\\/,"\\\\",$0)
            gsub(/"/,"\\\"",$0)

            printf "%s\"%s\"", sep, $0
            sep = ","
        }

        END {
            print "]"
        }
    '

elif [ "$longListing" -eq 1 ]; then

    printf '%s\n' "$nl_paths" |
    while IFS= read -r p; do
        [ -n "$p" ] && ls -ld "$p"
    done

else

    printf '%s\n' "$nl_paths"

fi

# ----------------------------------------------------------------------
# Summary
# ----------------------------------------------------------------------

if [ "$verbose" -eq 1 ]; then
    printf 'Found %d %s in "%s" %s, %s.\n' \
        "$result_count" \
        "$search_desc_type" \
        "$dirName" \
        "$search_pattern" \
        "$search_sort" >&2
fi

exit 0
