package utils

import (
	"fmt"
	"strings"
	"unicode"

	"github.com/rivo/uniseg"
	"github.com/samber/lo"
	"golang.org/x/exp/slices"
)

type Alignment int

const (
	AlignLeft Alignment = iota
	AlignRight
)

type ColumnConfig struct {
	Width     int
	Alignment Alignment
}

func StringWidth(s string) int {
	// We are intentionally not using a range loop here, because that would
	// convert the characters to runes, which is unnecessary work in this case.
	for i := range len(s) {
		if s[i] > unicode.MaxASCII {
			return uniseg.StringWidth(s)
		}
	}

	return len(s)
}

// WithPadding pads a string as much as you want
func WithPadding(str string, padding int, alignment Alignment) string {
	uncoloredStr := Decolorise(str)
	width := StringWidth(uncoloredStr)
	if padding < width {
		return str
	}
	space := strings.Repeat(" ", padding-width)
	if alignment == AlignLeft {
		return str + space
	}
	return space + str
}

// defaults to left-aligning each column. If you want to set the alignment of
// each column, pass in a slice of Alignment values.
// returns a list of strings that should be joined with "\n", and an array of
// the column positions
func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) ([]string, []int) {
	if len(displayStringsArr) == 0 {
		return []string{}, nil
	}

	displayStringsArr, columnAlignments, removedColumns := excludeBlankColumns(displayStringsArr, columnAlignments)
	padWidths := getPadWidths(displayStringsArr)
	columnConfigs := make([]ColumnConfig, len(padWidths))
	columnPositions := make([]int, len(padWidths)+1)
	columnPositions[0] = 0
	for i, padWidth := range padWidths {
		// gracefully handle when columnAlignments is shorter than padWidths
		alignment := AlignLeft
		if len(columnAlignments) > i {
			alignment = columnAlignments[i]
		}

		columnConfigs[i] = ColumnConfig{
			Width:     padWidth,
			Alignment: alignment,
		}
		columnPositions[i+1] = columnPositions[i] + padWidth + 1
	}
	// Add the removed columns back into columnPositions (a removed column gets
	// the same position as the following column); clients should be able to rely
	// on them all to be there
	for _, removedColumn := range removedColumns {
		if removedColumn < len(columnPositions) {
			columnPositions = slices.Insert(columnPositions, removedColumn, columnPositions[removedColumn])
		}
	}
	return getPaddedDisplayStrings(displayStringsArr, columnConfigs), columnPositions
}

// NOTE: this mutates the input slice for the sake of performance
func excludeBlankColumns(displayStringsArr [][]string, columnAlignments []Alignment) ([][]string, []Alignment, []int) {
	if len(displayStringsArr) == 0 {
		return displayStringsArr, columnAlignments, []int{}
	}

	// if all rows share a blank column, we want to remove that column
	toRemove := []int{}
outer:
	for i := range displayStringsArr[0] {
		for _, strings := range displayStringsArr {
			if strings[i] != "" {
				continue outer
			}
		}
		toRemove = append(toRemove, i)
	}

	if len(toRemove) == 0 {
		return displayStringsArr, columnAlignments, []int{}
	}

	// remove the columns
	for i, strings := range displayStringsArr {
		for j := len(toRemove) - 1; j >= 0; j-- {
			strings = slices.Delete(strings, toRemove[j], toRemove[j]+1)
		}
		displayStringsArr[i] = strings
	}

	for j := len(toRemove) - 1; j >= 0; j-- {
		if columnAlignments != nil && toRemove[j] < len(columnAlignments) {
			columnAlignments = slices.Delete(columnAlignments, toRemove[j], toRemove[j]+1)
		}
	}

	return displayStringsArr, columnAlignments, toRemove
}

func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) []string {
	result := make([]string, 0, len(stringArrays))
	for _, stringArray := range stringArrays {
		if len(stringArray) == 0 {
			continue
		}
		builder := strings.Builder{}
		for j, columnConfig := range columnConfigs {
			if len(stringArray)-1 < j {
				continue
			}
			builder.WriteString(WithPadding(stringArray[j], columnConfig.Width, columnConfig.Alignment))
			builder.WriteString(" ")
		}
		if len(stringArray)-1 < len(columnConfigs) {
			continue
		}
		builder.WriteString(stringArray[len(columnConfigs)])
		result = append(result, builder.String())
	}
	return result
}

func getPadWidths(stringArrays [][]string) []int {
	maxWidth := MaxFn(stringArrays, func(stringArray []string) int {
		return len(stringArray)
	})

	if maxWidth-1 < 0 {
		return []int{}
	}
	return lo.Map(lo.Range(maxWidth-1), func(i int, _ int) int {
		return MaxFn(stringArrays, func(stringArray []string) int {
			uncoloredStr := Decolorise(stringArray[i])

			return StringWidth(uncoloredStr)
		})
	})
}

func MaxFn[T any](items []T, fn func(T) int) int {
	max := 0
	for _, item := range items {
		if fn(item) > max {
			max = fn(item)
		}
	}
	return max
}

// TruncateWithEllipsis returns a string, truncated to a certain length, with an ellipsis
func TruncateWithEllipsis(str string, limit int) string {
	if StringWidth(str) <= limit {
		return str
	}
	if limit <= 2 {
		return strings.Repeat(".", limit)
	}

	state := -1
	var grapheme string
	var width int
	truncatedStr := ""

	for str != "" {
		grapheme, str, width, state = uniseg.FirstGraphemeClusterInString(str, state)
		if uniseg.StringWidth(truncatedStr)+width > limit-1 {
			break
		}
		truncatedStr += grapheme
	}
	return truncatedStr + "…"
}

func SafeTruncate(str string, limit int) string {
	if len(str) > limit {
		return str[0:limit]
	}
	return str
}

const COMMIT_HASH_SHORT_SIZE = 8

func ShortHash(hash string) string {
	if len(hash) < COMMIT_HASH_SHORT_SIZE {
		return hash
	}
	return hash[:COMMIT_HASH_SHORT_SIZE]
}

// Returns comma-separated list of paths, with ellipsis if there are more than 3
// e.g. "foo, bar, baz, [...3 more]"
func FormatPaths(paths []string) string {
	if len(paths) <= 3 {
		return strings.Join(paths, ", ")
	}
	return fmt.Sprintf("%s, %s, %s, [...%d more]", paths[0], paths[1], paths[2], len(paths)-3)
}
