package diagnosticwriter

import (
	"fmt"
	"io"
	"maps"
	"slices"
	"strconv"
	"strings"
	"unicode"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/diagnostics"
	"github.com/microsoft/typescript-go/internal/locale"
	"github.com/microsoft/typescript-go/internal/scanner"
	"github.com/microsoft/typescript-go/internal/tspath"
)

type FileLike interface {
	FileName() string
	Text() string
	ECMALineMap() []core.TextPos
}

// Diagnostic interface abstracts over ast.Diagnostic and LSP diagnostics
type Diagnostic interface {
	File() FileLike
	Pos() int
	End() int
	Len() int
	Code() int32
	Category() diagnostics.Category
	Localize(locale locale.Locale) string
	MessageChain() []Diagnostic
	RelatedInformation() []Diagnostic
}

// ASTDiagnostic wraps ast.Diagnostic to implement the Diagnostic interface
type ASTDiagnostic struct {
	*ast.Diagnostic
}

func (d *ASTDiagnostic) RelatedInformation() []Diagnostic {
	related := d.Diagnostic.RelatedInformation()
	result := make([]Diagnostic, len(related))
	for i, r := range related {
		result[i] = &ASTDiagnostic{r}
	}
	return result
}

func (d *ASTDiagnostic) File() FileLike {
	if file := d.Diagnostic.File(); file != nil {
		return file
	}
	return nil
}

func (d *ASTDiagnostic) MessageChain() []Diagnostic {
	chain := d.Diagnostic.MessageChain()
	result := make([]Diagnostic, len(chain))
	for i, c := range chain {
		result[i] = &ASTDiagnostic{c}
	}
	return result
}

func WrapASTDiagnostic(d *ast.Diagnostic) *ASTDiagnostic {
	return &ASTDiagnostic{d}
}

func WrapASTDiagnostics(diags []*ast.Diagnostic) []*ASTDiagnostic {
	result := make([]*ASTDiagnostic, len(diags))
	for i, d := range diags {
		result[i] = WrapASTDiagnostic(d)
	}
	return result
}

func FromASTDiagnostics(diags []*ast.Diagnostic) []Diagnostic {
	result := make([]Diagnostic, len(diags))
	for i, d := range diags {
		result[i] = WrapASTDiagnostic(d)
	}
	return result
}

func ToDiagnostics[T Diagnostic](diags []T) []Diagnostic {
	result := make([]Diagnostic, len(diags))
	for i, d := range diags {
		result[i] = d
	}
	return result
}

func CompareASTDiagnostics(a, b *ASTDiagnostic) int {
	return ast.CompareDiagnostics(a.Diagnostic, b.Diagnostic)
}

type FormattingOptions struct {
	Locale locale.Locale
	tspath.ComparePathsOptions
	NewLine string
}

const (
	foregroundColorEscapeGrey   = "\u001b[90m"
	foregroundColorEscapeRed    = "\u001b[91m"
	foregroundColorEscapeYellow = "\u001b[93m"
	foregroundColorEscapeBlue   = "\u001b[94m"
	foregroundColorEscapeCyan   = "\u001b[96m"
)

const (
	gutterStyleSequence = "\u001b[7m"
	gutterSeparator     = " "
	resetEscapeSequence = "\u001b[0m"
	ellipsis            = "..."
)

func FormatDiagnosticsWithColorAndContext(output io.Writer, diags []Diagnostic, formatOpts *FormattingOptions) {
	if len(diags) == 0 {
		return
	}
	for i, diagnostic := range diags {
		if i > 0 {
			fmt.Fprint(output, formatOpts.NewLine)
		}
		FormatDiagnosticWithColorAndContext(output, diagnostic, formatOpts)
	}
}

func FormatDiagnosticWithColorAndContext(output io.Writer, diagnostic Diagnostic, formatOpts *FormattingOptions) {
	if diagnostic.File() != nil {
		file := diagnostic.File()
		pos := diagnostic.Pos()
		WriteLocation(output, file, pos, formatOpts, writeWithStyleAndReset)
		fmt.Fprint(output, " - ")
	}

	writeWithStyleAndReset(output, diagnostic.Category().Name(), getCategoryFormat(diagnostic.Category()))
	fmt.Fprintf(output, "%s TS%d: %s", foregroundColorEscapeGrey, diagnostic.Code(), resetEscapeSequence)
	WriteFlattenedDiagnosticMessage(output, diagnostic, formatOpts.NewLine, formatOpts.Locale)

	if diagnostic.File() != nil && diagnostic.Code() != diagnostics.File_appears_to_be_binary.Code() {
		fmt.Fprint(output, formatOpts.NewLine)
		writeCodeSnippet(output, diagnostic.File(), diagnostic.Pos(), diagnostic.Len(), getCategoryFormat(diagnostic.Category()), "", formatOpts)
		fmt.Fprint(output, formatOpts.NewLine)
	}

	if (diagnostic.RelatedInformation() != nil) && (len(diagnostic.RelatedInformation()) > 0) {
		for _, relatedInformation := range diagnostic.RelatedInformation() {
			file := relatedInformation.File()
			if file != nil {
				fmt.Fprint(output, formatOpts.NewLine)
				fmt.Fprint(output, "  ")
				pos := relatedInformation.Pos()
				WriteLocation(output, file, pos, formatOpts, writeWithStyleAndReset)
				fmt.Fprint(output, " - ")
				WriteFlattenedDiagnosticMessage(output, relatedInformation, formatOpts.NewLine, formatOpts.Locale)
				writeCodeSnippet(output, file, pos, relatedInformation.Len(), foregroundColorEscapeCyan, "    ", formatOpts)
			}
			fmt.Fprint(output, formatOpts.NewLine)
		}
	}
}

func writeCodeSnippet(writer io.Writer, sourceFile FileLike, start int, length int, squiggleColor string, indent string, formatOpts *FormattingOptions) {
	firstLine, firstLineChar := scanner.GetECMALineAndCharacterOfPosition(sourceFile, start)
	lastLine, lastLineChar := scanner.GetECMALineAndCharacterOfPosition(sourceFile, start+length)
	if length == 0 {
		lastLineChar++ // When length is zero, squiggle the character right after the start position.
	}

	lastLineOfFile := scanner.GetECMALineOfPosition(sourceFile, len(sourceFile.Text()))

	hasMoreThanFiveLines := lastLine-firstLine >= 4
	gutterWidth := len(strconv.Itoa(lastLine + 1))
	if hasMoreThanFiveLines {
		gutterWidth = max(len(ellipsis), gutterWidth)
	}

	for i := firstLine; i <= lastLine; i++ {
		fmt.Fprint(writer, formatOpts.NewLine)

		// If the error spans over 5 lines, we'll only show the first 2 and last 2 lines,
		// so we'll skip ahead to the second-to-last line.
		if hasMoreThanFiveLines && firstLine+1 < i && i < lastLine-1 {
			fmt.Fprint(writer, indent)
			fmt.Fprint(writer, gutterStyleSequence)
			fmt.Fprintf(writer, "%*s", gutterWidth, ellipsis)
			fmt.Fprint(writer, resetEscapeSequence)
			fmt.Fprint(writer, gutterSeparator)
			fmt.Fprint(writer, formatOpts.NewLine)
			i = lastLine - 1
		}

		lineStart := scanner.GetECMAPositionOfLineAndCharacter(sourceFile, i, 0)
		var lineEnd int
		if i < lastLineOfFile {
			lineEnd = scanner.GetECMAPositionOfLineAndCharacter(sourceFile, i+1, 0)
		} else {
			lineEnd = len(sourceFile.Text())
		}

		lineContent := strings.TrimRightFunc(sourceFile.Text()[lineStart:lineEnd], unicode.IsSpace) // trim from end
		lineContent = strings.ReplaceAll(lineContent, "\t", " ")                                    // convert tabs to single spaces

		// Output the gutter and the actual contents of the line.
		fmt.Fprint(writer, indent)
		fmt.Fprint(writer, gutterStyleSequence)
		fmt.Fprintf(writer, "%*d", gutterWidth, i+1)
		fmt.Fprint(writer, resetEscapeSequence)
		fmt.Fprint(writer, gutterSeparator)
		fmt.Fprint(writer, lineContent)
		fmt.Fprint(writer, formatOpts.NewLine)

		// Output the gutter and the error span for the line using tildes.
		fmt.Fprint(writer, indent)
		fmt.Fprint(writer, gutterStyleSequence)
		fmt.Fprintf(writer, "%*s", gutterWidth, "")
		fmt.Fprint(writer, resetEscapeSequence)
		fmt.Fprint(writer, gutterSeparator)
		fmt.Fprint(writer, squiggleColor)
		switch i {
		case firstLine:
			// If we're on the last line, then limit it to the last character of the last line.
			// Otherwise, we'll just squiggle the rest of the line, giving 'slice' no end position.
			var lastCharForLine int
			if i == lastLine {
				lastCharForLine = lastLineChar
			} else {
				lastCharForLine = len(lineContent)
			}

			// Fill with spaces until the first character,
			// then squiggle the remainder of the line.
			fmt.Fprint(writer, strings.Repeat(" ", firstLineChar))
			fmt.Fprint(writer, strings.Repeat("~", lastCharForLine-firstLineChar))
		case lastLine:
			// Squiggle until the final character.
			fmt.Fprint(writer, strings.Repeat("~", lastLineChar))
		default:
			// Squiggle the entire line.
			fmt.Fprint(writer, strings.Repeat("~", len(lineContent)))
		}

		fmt.Fprint(writer, resetEscapeSequence)
	}
}

func FlattenDiagnosticMessage(d Diagnostic, newLine string, locale locale.Locale) string {
	var output strings.Builder
	WriteFlattenedDiagnosticMessage(&output, d, newLine, locale)
	return output.String()
}

func WriteFlattenedASTDiagnosticMessage(writer io.Writer, diagnostic *ast.Diagnostic, newline string, locale locale.Locale) {
	WriteFlattenedDiagnosticMessage(writer, WrapASTDiagnostic(diagnostic), newline, locale)
}

func WriteFlattenedDiagnosticMessage(writer io.Writer, diagnostic Diagnostic, newline string, locale locale.Locale) {
	fmt.Fprint(writer, diagnostic.Localize(locale))

	for _, chain := range diagnostic.MessageChain() {
		flattenDiagnosticMessageChain(writer, chain, newline, locale, 1 /*level*/)
	}
}

func flattenDiagnosticMessageChain(writer io.Writer, chain Diagnostic, newLine string, locale locale.Locale, level int) {
	fmt.Fprint(writer, newLine)
	for range level {
		fmt.Fprint(writer, "  ")
	}

	fmt.Fprint(writer, chain.Localize(locale))
	for _, child := range chain.MessageChain() {
		flattenDiagnosticMessageChain(writer, child, newLine, locale, level+1)
	}
}

func getCategoryFormat(category diagnostics.Category) string {
	switch category {
	case diagnostics.CategoryError:
		return foregroundColorEscapeRed
	case diagnostics.CategoryWarning:
		return foregroundColorEscapeYellow
	case diagnostics.CategorySuggestion:
		return foregroundColorEscapeGrey
	case diagnostics.CategoryMessage:
		return foregroundColorEscapeBlue
	}
	panic("Unhandled diagnostic category")
}

type FormattedWriter func(output io.Writer, text string, formatStyle string)

func writeWithStyleAndReset(output io.Writer, text string, formatStyle string) {
	fmt.Fprint(output, formatStyle)
	fmt.Fprint(output, text)
	fmt.Fprint(output, resetEscapeSequence)
}

func WriteLocation(output io.Writer, file FileLike, pos int, formatOpts *FormattingOptions, writeWithStyleAndReset FormattedWriter) {
	firstLine, firstChar := scanner.GetECMALineAndCharacterOfPosition(file, pos)
	var relativeFileName string
	if formatOpts != nil {
		relativeFileName = tspath.ConvertToRelativePath(file.FileName(), formatOpts.ComparePathsOptions)
	} else {
		relativeFileName = file.FileName()
	}

	writeWithStyleAndReset(output, relativeFileName, foregroundColorEscapeCyan)
	fmt.Fprint(output, ":")
	writeWithStyleAndReset(output, strconv.Itoa(firstLine+1), foregroundColorEscapeYellow)
	fmt.Fprint(output, ":")
	writeWithStyleAndReset(output, strconv.Itoa(firstChar+1), foregroundColorEscapeYellow)
}

// Some of these lived in watch.ts, but they're not specific to the watch API.

type ErrorSummary struct {
	TotalErrorCount int
	GlobalErrors    []Diagnostic
	ErrorsByFile    map[FileLike][]Diagnostic
	SortedFiles     []FileLike
}

func WriteErrorSummaryText(output io.Writer, allDiagnostics []Diagnostic, formatOpts *FormattingOptions) {
	// Roughly corresponds to 'getErrorSummaryText' from watch.ts

	errorSummary := getErrorSummary(allDiagnostics)
	totalErrorCount := errorSummary.TotalErrorCount
	if totalErrorCount == 0 {
		return
	}

	var firstFile FileLike
	if len(errorSummary.SortedFiles) > 0 {
		firstFile = errorSummary.SortedFiles[0]
	}
	firstFileName := prettyPathForFileError(firstFile, errorSummary.ErrorsByFile[firstFile], formatOpts)
	numErroringFiles := len(errorSummary.ErrorsByFile)

	var message string
	if totalErrorCount == 1 {
		// Special-case a single error.
		if len(errorSummary.GlobalErrors) > 0 || firstFileName == "" {
			message = diagnostics.Found_1_error.Localize(formatOpts.Locale)
		} else {
			message = diagnostics.Found_1_error_in_0.Localize(formatOpts.Locale, firstFileName)
		}
	} else {
		switch numErroringFiles {
		case 0:
			// No file-specific errors.
			message = diagnostics.Found_0_errors.Localize(formatOpts.Locale, totalErrorCount)
		case 1:
			// One file with errors.
			message = diagnostics.Found_0_errors_in_the_same_file_starting_at_Colon_1.Localize(formatOpts.Locale, totalErrorCount, firstFileName)
		default:
			// Multiple files with errors.
			message = diagnostics.Found_0_errors_in_1_files.Localize(formatOpts.Locale, totalErrorCount, numErroringFiles)
		}
	}
	fmt.Fprint(output, formatOpts.NewLine)
	fmt.Fprint(output, message)
	fmt.Fprint(output, formatOpts.NewLine)
	fmt.Fprint(output, formatOpts.NewLine)
	if numErroringFiles > 1 {
		writeTabularErrorsDisplay(output, errorSummary, formatOpts)
		fmt.Fprint(output, formatOpts.NewLine)
	}
}

func getErrorSummary(diags []Diagnostic) *ErrorSummary {
	var totalErrorCount int
	var globalErrors []Diagnostic
	var errorsByFile map[FileLike][]Diagnostic

	for _, diagnostic := range diags {
		if diagnostic.Category() != diagnostics.CategoryError {
			continue
		}

		totalErrorCount++
		if diagnostic.File() == nil {
			globalErrors = append(globalErrors, diagnostic)
		} else {
			if errorsByFile == nil {
				errorsByFile = make(map[FileLike][]Diagnostic)
			}
			errorsByFile[diagnostic.File()] = append(errorsByFile[diagnostic.File()], diagnostic)
		}
	}

	// !!!
	// Need an ordered map here, but sorting for consistency.
	sortedFiles := slices.SortedFunc(maps.Keys(errorsByFile), func(a, b FileLike) int {
		return strings.Compare(a.FileName(), b.FileName())
	})

	return &ErrorSummary{
		TotalErrorCount: totalErrorCount,
		GlobalErrors:    globalErrors,
		ErrorsByFile:    errorsByFile,
		SortedFiles:     sortedFiles,
	}
}

func writeTabularErrorsDisplay(output io.Writer, errorSummary *ErrorSummary, formatOpts *FormattingOptions) {
	sortedFiles := errorSummary.SortedFiles

	maxErrors := 0
	for _, errorsForFile := range errorSummary.ErrorsByFile {
		maxErrors = max(maxErrors, len(errorsForFile))
	}

	// !!!
	// TODO (drosen): This was never localized.
	// Should make this better.
	headerRow := diagnostics.Errors_Files.Localize(formatOpts.Locale)
	leftColumnHeadingLength := len(strings.Split(headerRow, " ")[0])
	lengthOfBiggestErrorCount := len(strconv.Itoa(maxErrors))
	leftPaddingGoal := max(leftColumnHeadingLength, lengthOfBiggestErrorCount)
	headerPadding := max(lengthOfBiggestErrorCount-leftColumnHeadingLength, 0)

	fmt.Fprint(output, strings.Repeat(" ", headerPadding))
	fmt.Fprint(output, headerRow)
	fmt.Fprint(output, formatOpts.NewLine)

	for _, file := range sortedFiles {
		fileErrors := errorSummary.ErrorsByFile[file]
		errorCount := len(fileErrors)

		fmt.Fprintf(output, "%*d  ", leftPaddingGoal, errorCount)
		fmt.Fprint(output, prettyPathForFileError(file, fileErrors, formatOpts))
		fmt.Fprint(output, formatOpts.NewLine)
	}
}

func prettyPathForFileError(file FileLike, fileErrors []Diagnostic, formatOpts *FormattingOptions) string {
	if file == nil || len(fileErrors) == 0 {
		return ""
	}
	line := scanner.GetECMALineOfPosition(file, fileErrors[0].Pos())
	fileName := file.FileName()
	if tspath.PathIsAbsolute(fileName) && tspath.PathIsAbsolute(formatOpts.CurrentDirectory) {
		fileName = tspath.ConvertToRelativePath(file.FileName(), formatOpts.ComparePathsOptions)
	}
	return fmt.Sprintf("%s%s:%d%s",
		fileName,
		foregroundColorEscapeGrey,
		line+1,
		resetEscapeSequence,
	)
}

func WriteFormatDiagnostics(output io.Writer, diagnostics []Diagnostic, formatOpts *FormattingOptions) {
	for _, diagnostic := range diagnostics {
		WriteFormatDiagnostic(output, diagnostic, formatOpts)
	}
}

func WriteFormatDiagnostic(output io.Writer, diagnostic Diagnostic, formatOpts *FormattingOptions) {
	if diagnostic.File() != nil {
		line, character := scanner.GetECMALineAndCharacterOfPosition(diagnostic.File(), diagnostic.Pos())
		fileName := diagnostic.File().FileName()
		relativeFileName := tspath.ConvertToRelativePath(fileName, formatOpts.ComparePathsOptions)
		fmt.Fprintf(output, "%s(%d,%d): ", relativeFileName, line+1, character+1)
	}

	fmt.Fprintf(output, "%s TS%d: ", diagnostic.Category().Name(), diagnostic.Code())
	WriteFlattenedDiagnosticMessage(output, diagnostic, formatOpts.NewLine, formatOpts.Locale)
	fmt.Fprint(output, formatOpts.NewLine)
}

func FormatDiagnosticsStatusWithColorAndTime(output io.Writer, time string, diag Diagnostic, formatOpts *FormattingOptions) {
	fmt.Fprint(output, "[")
	writeWithStyleAndReset(output, time, foregroundColorEscapeGrey)
	fmt.Fprint(output, "] ")
	WriteFlattenedDiagnosticMessage(output, diag, formatOpts.NewLine, formatOpts.Locale)
}

func FormatDiagnosticsStatusAndTime(output io.Writer, time string, diag Diagnostic, formatOpts *FormattingOptions) {
	fmt.Fprint(output, time, " - ")
	WriteFlattenedDiagnosticMessage(output, diag, formatOpts.NewLine, formatOpts.Locale)
}

var ScreenStartingCodes = []int32{
	diagnostics.Starting_compilation_in_watch_mode.Code(),
	diagnostics.File_change_detected_Starting_incremental_compilation.Code(),
}

func TryClearScreen(output io.Writer, diag Diagnostic, options *core.CompilerOptions) bool {
	if !options.PreserveWatchOutput.IsTrue() &&
		!options.ExtendedDiagnostics.IsTrue() &&
		!options.Diagnostics.IsTrue() &&
		slices.Contains(ScreenStartingCodes, diag.Code()) {
		fmt.Fprint(output, "\x1B[2J\x1B[3J\x1B[H") // Clear screen and move cursor to home position
		return true
	}
	return false
}
