package logger

// Logging is currently designed to look and feel like clang's error format.
// Errors are streamed asynchronously as they happen, each error contains the
// contents of the line with the error, and the error count is limited by
// default.

import (
	"fmt"
	"os"
	"runtime"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode/utf8"
)

const defaultTerminalWidth = 80

type Log struct {
	Level LogLevel

	AddMsg    func(Msg)
	HasErrors func() bool

	// This is called after the build has finished but before writing to stdout.
	// It exists to ensure that deferred warning messages end up in the terminal
	// before the data written to stdout.
	AlmostDone func()

	Done func() []Msg
}

type LogLevel int8

const (
	LevelNone LogLevel = iota
	LevelVerbose
	LevelDebug
	LevelInfo
	LevelWarning
	LevelError
	LevelSilent
)

type MsgKind uint8

const (
	Error MsgKind = iota
	Warning
	Info
	Note
	Debug
	Verbose
)

func (kind MsgKind) String() string {
	switch kind {
	case Error:
		return "ERROR"
	case Warning:
		return "WARNING"
	case Info:
		return "INFO"
	case Note:
		return "NOTE"
	case Debug:
		return "DEBUG"
	case Verbose:
		return "VERBOSE"
	default:
		panic("Internal error")
	}
}

func (kind MsgKind) Icon() string {
	// Special-case Windows command prompt, which only supports a few characters
	if isProbablyWindowsCommandPrompt() {
		switch kind {
		case Error:
			return "X"
		case Warning:
			return "▲"
		case Info:
			return "►"
		case Note:
			return "→"
		case Debug:
			return "●"
		case Verbose:
			return "♦"
		default:
			panic("Internal error")
		}
	}

	switch kind {
	case Error:
		return "✘"
	case Warning:
		return "▲"
	case Info:
		return "▶"
	case Note:
		return "→"
	case Debug:
		return "●"
	case Verbose:
		return "⬥"
	default:
		panic("Internal error")
	}
}

var windowsCommandPrompt struct {
	mutex         sync.Mutex
	once          bool
	isProbablyCMD bool
}

func isProbablyWindowsCommandPrompt() bool {
	windowsCommandPrompt.mutex.Lock()
	defer windowsCommandPrompt.mutex.Unlock()

	if !windowsCommandPrompt.once {
		windowsCommandPrompt.once = true

		// Assume we are running in Windows Command Prompt if we're on Windows. If
		// so, we can't use emoji because it won't be supported. Except we can
		// still use emoji if the WT_SESSION environment variable is present
		// because that means we're running in the new Windows Terminal instead.
		if runtime.GOOS == "windows" {
			windowsCommandPrompt.isProbablyCMD = true
			for _, env := range os.Environ() {
				if strings.HasPrefix(env, "WT_SESSION=") {
					windowsCommandPrompt.isProbablyCMD = false
					break
				}
			}
		}
	}

	return windowsCommandPrompt.isProbablyCMD
}

type Msg struct {
	PluginName string
	Kind       MsgKind
	Data       MsgData
	Notes      []MsgData
}

type MsgData struct {
	Text     string
	Location *MsgLocation

	// Optional user-specified data that is passed through unmodified
	UserDetail interface{}
}

type MsgLocation struct {
	File       string
	Namespace  string
	Line       int // 1-based
	Column     int // 0-based, in bytes
	Length     int // in bytes
	LineText   string
	Suggestion string
}

type Loc struct {
	// This is the 0-based index of this location from the start of the file, in bytes
	Start int32
}

type Range struct {
	Loc Loc
	Len int32
}

func (r Range) End() int32 {
	return r.Loc.Start + r.Len
}

type Span struct {
	Text  string
	Range Range
}

// This type is just so we can use Go's native sort function
type SortableMsgs []Msg

func (a SortableMsgs) Len() int          { return len(a) }
func (a SortableMsgs) Swap(i int, j int) { a[i], a[j] = a[j], a[i] }

func (a SortableMsgs) Less(i int, j int) bool {
	ai := a[i]
	aj := a[j]
	aiLoc := ai.Data.Location
	ajLoc := aj.Data.Location
	if aiLoc == nil || ajLoc == nil {
		return aiLoc == nil && ajLoc != nil
	}
	if aiLoc.File != ajLoc.File {
		return aiLoc.File < ajLoc.File
	}
	if aiLoc.Line != ajLoc.Line {
		return aiLoc.Line < ajLoc.Line
	}
	if aiLoc.Column != ajLoc.Column {
		return aiLoc.Column < ajLoc.Column
	}
	if ai.Kind != aj.Kind {
		return ai.Kind < aj.Kind
	}
	return ai.Data.Text < aj.Data.Text
}

// This is used to represent both file system paths (Namespace == "file") and
// abstract module paths (Namespace != "file"). Abstract module paths represent
// "virtual modules" when used for an input file and "package paths" when used
// to represent an external module.
type Path struct {
	Text      string
	Namespace string

	// This feature was added to support ancient CSS libraries that append things
	// like "?#iefix" and "#icons" to some of their import paths as a hack for IE6.
	// The intent is for these suffix parts to be ignored but passed through to
	// the output. This is supported by other bundlers, so we also support this.
	IgnoredSuffix string

	Flags PathFlags
}

type PathFlags uint8

const (
	// This corresponds to a value of "false' in the "browser" package.json field
	PathDisabled PathFlags = 1 << iota
)

func (p Path) IsDisabled() bool {
	return (p.Flags & PathDisabled) != 0
}

func (a Path) ComesBeforeInSortedOrder(b Path) bool {
	return a.Namespace > b.Namespace ||
		(a.Namespace == b.Namespace && (a.Text < b.Text ||
			(a.Text == b.Text && (a.Flags < b.Flags ||
				(a.Flags == b.Flags && a.IgnoredSuffix < b.IgnoredSuffix)))))
}

var noColorResult bool
var noColorOnce sync.Once

func hasNoColorEnvironmentVariable() bool {
	noColorOnce.Do(func() {
		for _, key := range os.Environ() {
			// Read "NO_COLOR" from the environment. This is a convention that some
			// software follows. See https://no-color.org/ for more information.
			if strings.HasPrefix(key, "NO_COLOR=") {
				noColorResult = true
			}
		}
	})
	return noColorResult
}

// This has a custom implementation instead of using "filepath.Dir/Base/Ext"
// because it should work the same on Unix and Windows. These names end up in
// the generated output and the generated output should not depend on the OS.
func PlatformIndependentPathDirBaseExt(path string) (dir string, base string, ext string) {
	for {
		i := strings.LastIndexAny(path, "/\\")

		// Stop if there are no more slashes
		if i < 0 {
			base = path
			break
		}

		// Stop if we found a non-trailing slash
		if i+1 != len(path) {
			dir, base = path[:i], path[i+1:]
			break
		}

		// Ignore trailing slashes
		path = path[:i]
	}

	// Strip off the extension
	if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
		base, ext = base[:dot], base[dot:]
	}

	return
}

type Source struct {
	Index uint32

	// This is used as a unique key to identify this source file. It should never
	// be shown to the user (e.g. never print this to the terminal).
	//
	// If it's marked as an absolute path, it's a platform-dependent path that
	// includes environment-specific things such as Windows backslash path
	// separators and potentially the user's home directory. Only use this for
	// passing to syscalls for reading and writing to the file system. Do not
	// include this in any output data.
	//
	// If it's marked as not an absolute path, it's an opaque string that is used
	// to refer to an automatically-generated module.
	KeyPath Path

	// This is used for error messages and the metadata JSON file.
	//
	// This is a mostly platform-independent path. It's relative to the current
	// working directory and always uses standard path separators. Use this for
	// referencing a file in all output data. These paths still use the original
	// case of the path so they may still work differently on file systems that
	// are case-insensitive vs. case-sensitive.
	PrettyPath string

	// An identifier that is mixed in to automatically-generated symbol names to
	// improve readability. For example, if the identifier is "util" then the
	// symbol for an "export default" statement will be called "util_default".
	IdentifierName string

	Contents string
}

func (s *Source) TextForRange(r Range) string {
	return s.Contents[r.Loc.Start : r.Loc.Start+r.Len]
}

func (s *Source) LocBeforeWhitespace(loc Loc) Loc {
	for loc.Start > 0 {
		c, width := utf8.DecodeLastRuneInString(s.Contents[:loc.Start])
		if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
			break
		}
		loc.Start -= int32(width)
	}
	return loc
}

func (s *Source) RangeOfOperatorBefore(loc Loc, op string) Range {
	text := s.Contents[:loc.Start]
	index := strings.LastIndex(text, op)
	if index >= 0 {
		return Range{Loc: Loc{Start: int32(index)}, Len: int32(len(op))}
	}
	return Range{Loc: loc}
}

func (s *Source) RangeOfOperatorAfter(loc Loc, op string) Range {
	text := s.Contents[loc.Start:]
	index := strings.Index(text, op)
	if index >= 0 {
		return Range{Loc: Loc{Start: loc.Start + int32(index)}, Len: int32(len(op))}
	}
	return Range{Loc: loc}
}

func (s *Source) RangeOfString(loc Loc) Range {
	text := s.Contents[loc.Start:]
	if len(text) == 0 {
		return Range{Loc: loc, Len: 0}
	}

	quote := text[0]
	if quote == '"' || quote == '\'' {
		// Search for the matching quote character
		for i := 1; i < len(text); i++ {
			c := text[i]
			if c == quote {
				return Range{Loc: loc, Len: int32(i + 1)}
			} else if c == '\\' {
				i += 1
			}
		}
	}

	return Range{Loc: loc, Len: 0}
}

func (s *Source) RangeOfNumber(loc Loc) (r Range) {
	text := s.Contents[loc.Start:]
	r = Range{Loc: loc, Len: 0}

	if len(text) > 0 {
		if c := text[0]; c >= '0' && c <= '9' {
			r.Len = 1
			for int(r.Len) < len(text) {
				c := text[r.Len]
				if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '.' && c != '_' {
					break
				}
				r.Len++
			}
		}
	}
	return
}

func (s *Source) RangeOfLegacyOctalEscape(loc Loc) (r Range) {
	text := s.Contents[loc.Start:]
	r = Range{Loc: loc, Len: 0}

	if len(text) >= 2 && text[0] == '\\' {
		r.Len = 2
		for r.Len < 4 && int(r.Len) < len(text) {
			c := text[r.Len]
			if c < '0' || c > '9' {
				break
			}
			r.Len++
		}
	}
	return
}

func plural(prefix string, count int, shown int, someAreMissing bool) string {
	var text string
	if count == 1 {
		text = fmt.Sprintf("%d %s", count, prefix)
	} else {
		text = fmt.Sprintf("%d %ss", count, prefix)
	}
	if shown < count {
		text = fmt.Sprintf("%d of %s", shown, text)
	} else if someAreMissing && count > 1 {
		text = "all " + text
	}
	return text
}

func errorAndWarningSummary(errors int, warnings int, shownErrors int, shownWarnings int) string {
	someAreMissing := shownWarnings < warnings || shownErrors < errors
	switch {
	case errors == 0:
		return plural("warning", warnings, shownWarnings, someAreMissing)
	case warnings == 0:
		return plural("error", errors, shownErrors, someAreMissing)
	default:
		return fmt.Sprintf("%s and %s",
			plural("warning", warnings, shownWarnings, someAreMissing),
			plural("error", errors, shownErrors, someAreMissing))
	}
}

type APIKind uint8

const (
	GoAPI APIKind = iota
	CLIAPI
	JSAPI
)

// This can be used to customize error messages for the current API kind
var API APIKind

type TerminalInfo struct {
	IsTTY           bool
	UseColorEscapes bool
	Width           int
	Height          int
}

func NewStderrLog(options OutputOptions) Log {
	var mutex sync.Mutex
	var msgs SortableMsgs
	terminalInfo := GetTerminalInfo(os.Stderr)
	errors := 0
	warnings := 0
	shownErrors := 0
	shownWarnings := 0
	hasErrors := false
	remainingMessagesBeforeLimit := options.MessageLimit
	if remainingMessagesBeforeLimit == 0 {
		remainingMessagesBeforeLimit = 0x7FFFFFFF
	}
	var deferredWarnings []Msg
	didFinalizeLog := false

	finalizeLog := func() {
		if didFinalizeLog {
			return
		}
		didFinalizeLog = true

		// Print the deferred warning now if there was no error after all
		for remainingMessagesBeforeLimit > 0 && len(deferredWarnings) > 0 {
			shownWarnings++
			writeStringWithColor(os.Stderr, deferredWarnings[0].String(options, terminalInfo))
			deferredWarnings = deferredWarnings[1:]
			remainingMessagesBeforeLimit--
		}

		// Print out a summary
		if options.MessageLimit > 0 && errors+warnings > options.MessageLimit {
			writeStringWithColor(os.Stderr, fmt.Sprintf("%s shown (disable the message limit with --log-limit=0)\n",
				errorAndWarningSummary(errors, warnings, shownErrors, shownWarnings)))
		} else if options.LogLevel <= LevelInfo && (warnings != 0 || errors != 0) {
			writeStringWithColor(os.Stderr, fmt.Sprintf("%s\n",
				errorAndWarningSummary(errors, warnings, shownErrors, shownWarnings)))
		}
	}

	switch options.Color {
	case ColorNever:
		terminalInfo.UseColorEscapes = false
	case ColorAlways:
		terminalInfo.UseColorEscapes = SupportsColorEscapes
	}

	return Log{
		Level: options.LogLevel,

		AddMsg: func(msg Msg) {
			mutex.Lock()
			defer mutex.Unlock()
			msgs = append(msgs, msg)

			switch msg.Kind {
			case Verbose:
				if options.LogLevel <= LevelVerbose {
					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
				}

			case Debug:
				if options.LogLevel <= LevelDebug {
					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
				}

			case Info:
				if options.LogLevel <= LevelInfo {
					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
				}

			case Error:
				hasErrors = true
				if options.LogLevel <= LevelError {
					errors++
				}

			case Warning:
				if options.LogLevel <= LevelWarning {
					warnings++
				}
			}

			// Be silent if we're past the limit so we don't flood the terminal
			if remainingMessagesBeforeLimit == 0 {
				return
			}

			switch msg.Kind {
			case Error:
				if options.LogLevel <= LevelError {
					shownErrors++
					writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
					remainingMessagesBeforeLimit--
				}

			case Warning:
				if options.LogLevel <= LevelWarning {
					if remainingMessagesBeforeLimit > (options.MessageLimit+1)/2 {
						shownWarnings++
						writeStringWithColor(os.Stderr, msg.String(options, terminalInfo))
						remainingMessagesBeforeLimit--
					} else {
						// If we have less than half of the slots left, wait for potential
						// future errors instead of using up all of the slots with warnings.
						// We want the log for a failed build to always have at least one
						// error in it.
						deferredWarnings = append(deferredWarnings, msg)
					}
				}
			}
		},

		HasErrors: func() bool {
			mutex.Lock()
			defer mutex.Unlock()
			return hasErrors
		},

		AlmostDone: func() {
			mutex.Lock()
			defer mutex.Unlock()

			finalizeLog()
		},

		Done: func() []Msg {
			mutex.Lock()
			defer mutex.Unlock()

			finalizeLog()
			sort.Stable(msgs)
			return msgs
		},
	}
}

func PrintErrorToStderr(osArgs []string, text string) {
	PrintMessageToStderr(osArgs, Msg{Kind: Error, Data: MsgData{Text: text}})
}

func OutputOptionsForArgs(osArgs []string) OutputOptions {
	options := OutputOptions{IncludeSource: true}

	// Implement a mini argument parser so these options always work even if we
	// haven't yet gotten to the general-purpose argument parsing code
	for _, arg := range osArgs {
		switch arg {
		case "--color=false":
			options.Color = ColorNever
		case "--color=true":
			options.Color = ColorAlways
		case "--log-level=info":
			options.LogLevel = LevelInfo
		case "--log-level=warning":
			options.LogLevel = LevelWarning
		case "--log-level=error":
			options.LogLevel = LevelError
		case "--log-level=silent":
			options.LogLevel = LevelSilent
		}
	}

	return options
}

func PrintMessageToStderr(osArgs []string, msg Msg) {
	log := NewStderrLog(OutputOptionsForArgs(osArgs))
	log.AddMsg(msg)
	log.Done()
}

type Colors struct {
	Reset     string
	Bold      string
	Dim       string
	Underline string

	Red   string
	Green string
	Blue  string

	Cyan    string
	Magenta string
	Yellow  string

	RedBgRed     string
	RedBgWhite   string
	GreenBgGreen string
	GreenBgWhite string
	BlueBgBlue   string
	BlueBgWhite  string

	CyanBgCyan       string
	CyanBgBlack      string
	MagentaBgMagenta string
	MagentaBgBlack   string
	YellowBgYellow   string
	YellowBgBlack    string
}

var TerminalColors = Colors{
	Reset:     "\033[0m",
	Bold:      "\033[1m",
	Dim:       "\033[37m",
	Underline: "\033[4m",

	Red:   "\033[31m",
	Green: "\033[32m",
	Blue:  "\033[34m",

	Cyan:    "\033[36m",
	Magenta: "\033[35m",
	Yellow:  "\033[33m",

	RedBgRed:     "\033[41;31m",
	RedBgWhite:   "\033[41;97m",
	GreenBgGreen: "\033[42;32m",
	GreenBgWhite: "\033[42;97m",
	BlueBgBlue:   "\033[44;34m",
	BlueBgWhite:  "\033[44;97m",

	CyanBgCyan:       "\033[46;36m",
	CyanBgBlack:      "\033[46;30m",
	MagentaBgMagenta: "\033[45;35m",
	MagentaBgBlack:   "\033[45;30m",
	YellowBgYellow:   "\033[43;33m",
	YellowBgBlack:    "\033[43;30m",
}

func PrintText(file *os.File, level LogLevel, osArgs []string, callback func(Colors) string) {
	options := OutputOptionsForArgs(osArgs)

	// Skip logging these if these logs are disabled
	if options.LogLevel > level {
		return
	}

	PrintTextWithColor(file, options.Color, callback)
}

func PrintTextWithColor(file *os.File, useColor UseColor, callback func(Colors) string) {
	var useColorEscapes bool
	switch useColor {
	case ColorNever:
		useColorEscapes = false
	case ColorAlways:
		useColorEscapes = SupportsColorEscapes
	case ColorIfTerminal:
		useColorEscapes = GetTerminalInfo(file).UseColorEscapes
	}

	var colors Colors
	if useColorEscapes {
		colors = TerminalColors
	}
	writeStringWithColor(file, callback(colors))
}

type SummaryTableEntry struct {
	Dir         string
	Base        string
	Size        string
	Bytes       int
	IsSourceMap bool
}

// This type is just so we can use Go's native sort function
type SummaryTable []SummaryTableEntry

func (t SummaryTable) Len() int          { return len(t) }
func (t SummaryTable) Swap(i int, j int) { t[i], t[j] = t[j], t[i] }

func (t SummaryTable) Less(i int, j int) bool {
	ti := t[i]
	tj := t[j]

	// Sort source maps last
	if !ti.IsSourceMap && tj.IsSourceMap {
		return true
	}
	if ti.IsSourceMap && !tj.IsSourceMap {
		return false
	}

	// Sort by size first
	if ti.Bytes > tj.Bytes {
		return true
	}
	if ti.Bytes < tj.Bytes {
		return false
	}

	// Sort alphabetically by directory first
	if ti.Dir < tj.Dir {
		return true
	}
	if ti.Dir > tj.Dir {
		return false
	}

	// Then sort alphabetically by file name
	return ti.Base < tj.Base
}

// Show a warning icon next to output files that are 1mb or larger
const sizeWarningThreshold = 1024 * 1024

func PrintSummary(useColor UseColor, table SummaryTable, start *time.Time) {
	PrintTextWithColor(os.Stderr, useColor, func(colors Colors) string {
		isProbablyWindowsCommandPrompt := isProbablyWindowsCommandPrompt()
		sb := strings.Builder{}

		if len(table) > 0 {
			info := GetTerminalInfo(os.Stderr)

			// Truncate the table in case it's really long
			maxLength := info.Height / 2
			if info.Height == 0 {
				maxLength = 20
			} else if maxLength < 5 {
				maxLength = 5
			}
			length := len(table)
			sort.Sort(table)
			if length > maxLength {
				table = table[:maxLength]
			}

			// Compute the maximum width of the size column
			spacingBetweenColumns := 2
			hasSizeWarning := false
			maxPath := 0
			maxSize := 0
			for _, entry := range table {
				path := len(entry.Dir) + len(entry.Base)
				size := len(entry.Size) + spacingBetweenColumns
				if path > maxPath {
					maxPath = path
				}
				if size > maxSize {
					maxSize = size
				}
				if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold {
					hasSizeWarning = true
				}
			}

			margin := "  "
			layoutWidth := info.Width
			if layoutWidth < 1 {
				layoutWidth = defaultTerminalWidth
			}
			layoutWidth -= 2 * len(margin)
			if hasSizeWarning {
				// Add space for the warning icon
				layoutWidth -= 2
			}
			if layoutWidth > maxPath+maxSize {
				layoutWidth = maxPath + maxSize
			}
			sb.WriteByte('\n')

			for _, entry := range table {
				dir, base := entry.Dir, entry.Base
				pathWidth := layoutWidth - maxSize

				// Truncate the path with "..." to fit on one line
				if len(dir)+len(base) > pathWidth {
					// Trim the directory from the front, leaving the trailing slash
					if len(dir) > 0 {
						n := pathWidth - len(base) - 3
						if n < 1 {
							n = 1
						}
						dir = "..." + dir[len(dir)-n:]
					}

					// Trim the file name from the back
					if len(dir)+len(base) > pathWidth {
						n := pathWidth - len(dir) - 3
						if n < 0 {
							n = 0
						}
						base = base[:n] + "..."
					}
				}

				spacer := layoutWidth - len(entry.Size) - len(dir) - len(base)
				if spacer < 0 {
					spacer = 0
				}

				// Put a warning next to the size if it's above a certain threshold
				sizeColor := colors.Cyan
				sizeWarning := ""
				if !entry.IsSourceMap && entry.Bytes >= sizeWarningThreshold {
					sizeColor = colors.Yellow

					// Emoji don't work in Windows Command Prompt
					if !isProbablyWindowsCommandPrompt {
						sizeWarning = " ⚠️"
					}
				}

				sb.WriteString(fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s%s\n",
					margin,
					colors.Dim,
					dir,
					colors.Reset,
					colors.Bold,
					base,
					colors.Reset,
					strings.Repeat(" ", spacer),
					sizeColor,
					entry.Size,
					sizeWarning,
					colors.Reset,
				))
			}

			// Say how many remaining files are not shown
			if length > maxLength {
				plural := "s"
				if length == maxLength+1 {
					plural = ""
				}
				sb.WriteString(fmt.Sprintf("%s%s...and %d more output file%s...%s\n", margin, colors.Dim, length-maxLength, plural, colors.Reset))
			}
		}
		sb.WriteByte('\n')

		lightningSymbol := "⚡ "

		// Emoji don't work in Windows Command Prompt
		if isProbablyWindowsCommandPrompt {
			lightningSymbol = ""
		}

		// Printing the time taken is optional
		if start != nil {
			sb.WriteString(fmt.Sprintf("%s%sDone in %dms%s\n",
				lightningSymbol,
				colors.Green,
				time.Since(*start).Milliseconds(),
				colors.Reset,
			))
		}

		return sb.String()
	})
}

type DeferLogKind uint8

const (
	DeferLogAll DeferLogKind = iota
	DeferLogNoVerboseOrDebug
)

func NewDeferLog(kind DeferLogKind) Log {
	var msgs SortableMsgs
	var mutex sync.Mutex
	var hasErrors bool

	return Log{
		Level: LevelInfo,

		AddMsg: func(msg Msg) {
			if kind == DeferLogNoVerboseOrDebug && (msg.Kind == Verbose || msg.Kind == Debug) {
				return
			}
			mutex.Lock()
			defer mutex.Unlock()
			if msg.Kind == Error {
				hasErrors = true
			}
			msgs = append(msgs, msg)
		},

		HasErrors: func() bool {
			mutex.Lock()
			defer mutex.Unlock()
			return hasErrors
		},

		AlmostDone: func() {
		},

		Done: func() []Msg {
			mutex.Lock()
			defer mutex.Unlock()
			sort.Stable(msgs)
			return msgs
		},
	}
}

type UseColor uint8

const (
	ColorIfTerminal UseColor = iota
	ColorNever
	ColorAlways
)

type OutputOptions struct {
	IncludeSource bool
	MessageLimit  int
	Color         UseColor
	LogLevel      LogLevel
}

func (msg Msg) String(options OutputOptions, terminalInfo TerminalInfo) string {
	// Format the message
	text := msgString(options.IncludeSource, terminalInfo, msg.Kind, msg.Data, msg.PluginName)

	// Format the notes
	var oldData MsgData
	for i, note := range msg.Notes {
		if options.IncludeSource && (i == 0 || strings.IndexByte(oldData.Text, '\n') >= 0 || oldData.Location != nil) {
			text += "\n"
		}
		text += msgString(options.IncludeSource, terminalInfo, Note, note, "")
		oldData = note
	}

	// Add extra spacing between messages if source code is present
	if options.IncludeSource {
		text += "\n"
	}
	return text
}

// The number of margin characters in addition to the line number
const extraMarginChars = 9

func marginWithLineText(maxMargin int, line int) string {
	number := fmt.Sprintf("%d", line)
	return fmt.Sprintf("      %s%s │ ", strings.Repeat(" ", maxMargin-len(number)), number)
}

func emptyMarginText(maxMargin int, isLast bool) string {
	space := strings.Repeat(" ", maxMargin)
	if isLast {
		return fmt.Sprintf("      %s ╵ ", space)
	}
	return fmt.Sprintf("      %s │ ", space)
}

func msgString(includeSource bool, terminalInfo TerminalInfo, kind MsgKind, data MsgData, pluginName string) string {
	if !includeSource {
		if loc := data.Location; loc != nil {
			return fmt.Sprintf("%s: %s: %s\n", loc.File, kind.String(), data.Text)
		}
		return fmt.Sprintf("%s: %s\n", kind.String(), data.Text)
	}

	var colors Colors
	if terminalInfo.UseColorEscapes {
		colors = TerminalColors
	}

	var iconColor string
	var kindColorBrackets string
	var kindColorText string

	location := ""

	if data.Location != nil {
		maxMargin := len(fmt.Sprintf("%d", data.Location.Line))
		d := detailStruct(data, terminalInfo, maxMargin)

		if d.Suggestion != "" {
			location = fmt.Sprintf("\n    %s:%d:%d:\n%s%s%s%s%s%s\n%s%s%s%s%s\n%s%s%s%s%s\n%s",
				d.Path, d.Line, d.Column,
				colors.Dim, d.SourceBefore, colors.Green, d.SourceMarked, colors.Dim, d.SourceAfter,
				emptyMarginText(maxMargin, false), d.Indent, colors.Green, d.Marker, colors.Dim,
				emptyMarginText(maxMargin, true), d.Indent, colors.Green, d.Suggestion, colors.Reset,
				d.ContentAfter,
			)
		} else {
			location = fmt.Sprintf("\n    %s:%d:%d:\n%s%s%s%s%s%s\n%s%s%s%s%s\n%s",
				d.Path, d.Line, d.Column,
				colors.Dim, d.SourceBefore, colors.Green, d.SourceMarked, colors.Dim, d.SourceAfter,
				emptyMarginText(maxMargin, true), d.Indent, colors.Green, d.Marker, colors.Reset,
				d.ContentAfter,
			)
		}
	}

	switch kind {
	case Verbose:
		iconColor = colors.Cyan
		kindColorBrackets = colors.CyanBgCyan
		kindColorText = colors.CyanBgBlack

	case Debug:
		iconColor = colors.Green
		kindColorBrackets = colors.GreenBgGreen
		kindColorText = colors.GreenBgWhite

	case Info:
		iconColor = colors.Blue
		kindColorBrackets = colors.BlueBgBlue
		kindColorText = colors.BlueBgWhite

	case Error:
		iconColor = colors.Red
		kindColorBrackets = colors.RedBgRed
		kindColorText = colors.RedBgWhite

	case Warning:
		iconColor = colors.Yellow
		kindColorBrackets = colors.YellowBgYellow
		kindColorText = colors.YellowBgBlack

	case Note:
		sb := strings.Builder{}

		for _, line := range strings.Split(data.Text, "\n") {
			// Special-case word wrapping
			if wrapWidth := terminalInfo.Width; wrapWidth > 2 {
				if wrapWidth > 100 {
					wrapWidth = 100 // Enforce a maximum paragraph width for readability
				}
				for _, run := range wrapWordsInString(line, wrapWidth-2) {
					sb.WriteString("  ")
					sb.WriteString(linkifyText(run, colors.Underline, colors.Reset))
					sb.WriteByte('\n')
				}
				continue
			}

			// Otherwise, just write an indented line
			sb.WriteString("  ")
			sb.WriteString(linkifyText(line, colors.Underline, colors.Reset))
			sb.WriteByte('\n')
		}

		sb.WriteString(location)
		return sb.String()
	}

	if pluginName != "" {
		pluginName = fmt.Sprintf("%s%s[plugin %s]%s ", colors.Bold, colors.Magenta, pluginName, colors.Reset)
	}

	return fmt.Sprintf("%s%s %s[%s%s%s]%s %s%s%s%s\n%s",
		iconColor, kind.Icon(),
		kindColorBrackets, kindColorText, kind.String(), kindColorBrackets, colors.Reset,
		pluginName,
		colors.Bold, data.Text, colors.Reset,
		location,
	)
}

func linkifyText(text string, underline string, reset string) string {
	if underline == "" {
		return text
	}

	https := strings.Index(text, "https://")
	if https == -1 {
		return text
	}

	sb := strings.Builder{}
	for {
		https := strings.Index(text, "https://")
		if https == -1 {
			break
		}

		end := strings.IndexByte(text[https:], ' ')
		if end == -1 {
			end = len(text)
		} else {
			end += https
		}

		// Remove trailing punctuation
		if end > https {
			switch text[end-1] {
			case '.', ',', '?', '!', ')', ']', '}':
				end--
			}
		}

		sb.WriteString(text[:https])
		sb.WriteString(underline)
		sb.WriteString(text[https:end])
		sb.WriteString(reset)
		text = text[end:]
	}

	sb.WriteString(text)
	return sb.String()
}

func wrapWordsInString(text string, width int) []string {
	runs := []string{}

outer:
	for text != "" {
		i := 0
		x := 0
		wordEndI := 0

		// Skip over any leading spaces
		for i < len(text) && text[i] == ' ' {
			i++
			x++
		}

		// Find out how many words will fit in this run
		for i < len(text) {
			oldWordEndI := wordEndI
			wordStartI := i

			// Find the end of the word
			for i < len(text) {
				c, width := utf8.DecodeRuneInString(text[i:])
				if c == ' ' {
					break
				}
				i += width
				x += 1 // Naively assume that each unicode code point is a single column
			}
			wordEndI = i

			// Split into a new run if this isn't the first word in the run and the end is past the width
			if wordStartI > 0 && x > width {
				runs = append(runs, text[:oldWordEndI])
				text = text[wordStartI:]
				continue outer
			}

			// Skip over any spaces after the word
			for i < len(text) && text[i] == ' ' {
				i++
				x++
			}
		}

		// If we get here, this is the last run (i.e. everything fits)
		break
	}

	// Remove any trailing spaces on the last run
	for len(text) > 0 && text[len(text)-1] == ' ' {
		text = text[:len(text)-1]
	}
	runs = append(runs, text)
	return runs
}

type MsgDetail struct {
	Path   string
	Line   int
	Column int

	SourceBefore string
	SourceMarked string
	SourceAfter  string

	Indent     string
	Marker     string
	Suggestion string

	ContentAfter string
}

// It's not common for large files to have many warnings. But when it happens,
// we want to make sure that it's not too slow. Source code locations are
// represented as byte offsets for compactness but transforming these to
// line/column locations for warning messages requires scanning through the
// file. A naive approach for this would cause O(n^2) scanning time for n
// warnings distributed throughout the file.
//
// Warnings are typically generated sequentially as the file is scanned. So
// one way of optimizing this is to just start scanning from where we left
// off last time instead of always starting from the beginning of the file.
// That's what this object does.
//
// Another option could be to eagerly populate an array of line/column offsets
// and then use binary search for each query. This might slow down the common
// case of a file with only at most a few warnings though, so think before
// optimizing too much. Performance in the zero or one warning case is by far
// the most important.
type LineColumnTracker struct {
	contents     string
	prettyPath   string
	offset       int32
	line         int32
	lineStart    int32
	lineEnd      int32
	hasLineStart bool
	hasLineEnd   bool
	hasSource    bool
}

func MakeLineColumnTracker(source *Source) LineColumnTracker {
	if source == nil {
		return LineColumnTracker{
			hasSource: false,
		}
	}

	return LineColumnTracker{
		contents:     source.Contents,
		prettyPath:   source.PrettyPath,
		hasLineStart: true,
		hasSource:    true,
	}
}

func (tracker *LineColumnTracker) MsgData(r Range, text string) MsgData {
	return MsgData{
		Text:     text,
		Location: tracker.MsgLocationOrNil(r),
	}
}

func (t *LineColumnTracker) scanTo(offset int32) {
	contents := t.contents
	i := t.offset

	// Scan forward
	if i < offset {
		for {
			r, size := utf8.DecodeRuneInString(contents[i:])
			i += int32(size)

			switch r {
			case '\n':
				t.hasLineStart = true
				t.hasLineEnd = false
				t.lineStart = i
				if i == int32(size) || contents[i-int32(size)-1] != '\r' {
					t.line++
				}

			case '\r', '\u2028', '\u2029':
				t.hasLineStart = true
				t.hasLineEnd = false
				t.lineStart = i
				t.line++
			}

			if i >= offset {
				t.offset = i
				return
			}
		}
	}

	// Scan backward
	if i > offset {
		for {
			r, size := utf8.DecodeLastRuneInString(contents[:i])
			i -= int32(size)

			switch r {
			case '\n':
				t.hasLineStart = false
				t.hasLineEnd = true
				t.lineEnd = i
				if i == 0 || contents[i-1] != '\r' {
					t.line--
				}

			case '\r', '\u2028', '\u2029':
				t.hasLineStart = false
				t.hasLineEnd = true
				t.lineEnd = i
				t.line--
			}

			if i <= offset {
				t.offset = i
				return
			}
		}
	}
}

func (t *LineColumnTracker) computeLineAndColumn(offset int) (lineCount int, columnCount int, lineStart int, lineEnd int) {
	t.scanTo(int32(offset))

	// Scan for the start of the line
	if !t.hasLineStart {
		contents := t.contents
		i := t.offset
		for i > 0 {
			r, size := utf8.DecodeLastRuneInString(contents[:i])
			if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
				break
			}
			i -= int32(size)
		}
		t.hasLineStart = true
		t.lineStart = i
	}

	// Scan for the end of the line
	if !t.hasLineEnd {
		contents := t.contents
		i := t.offset
		n := int32(len(contents))
		for i < n {
			r, size := utf8.DecodeRuneInString(contents[i:])
			if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
				break
			}
			i += int32(size)
		}
		t.hasLineEnd = true
		t.lineEnd = i
	}

	return int(t.line), offset - int(t.lineStart), int(t.lineStart), int(t.lineEnd)
}

func (tracker *LineColumnTracker) MsgLocationOrNil(r Range) *MsgLocation {
	if tracker == nil || !tracker.hasSource {
		return nil
	}

	// Convert the index into a line and column number
	lineCount, columnCount, lineStart, lineEnd := tracker.computeLineAndColumn(int(r.Loc.Start))

	return &MsgLocation{
		File:     tracker.prettyPath,
		Line:     lineCount + 1, // 0-based to 1-based
		Column:   columnCount,
		Length:   int(r.Len),
		LineText: tracker.contents[lineStart:lineEnd],
	}
}

func detailStruct(data MsgData, terminalInfo TerminalInfo, maxMargin int) MsgDetail {
	// Only highlight the first line of the line text
	loc := *data.Location
	endOfFirstLine := len(loc.LineText)
	for i, c := range loc.LineText {
		if c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029' {
			endOfFirstLine = i
			break
		}
	}
	firstLine := loc.LineText[:endOfFirstLine]
	afterFirstLine := loc.LineText[endOfFirstLine:]
	if afterFirstLine != "" && !strings.HasSuffix(afterFirstLine, "\n") {
		afterFirstLine += "\n"
	}

	// Clamp values in range
	if loc.Line < 0 {
		loc.Line = 0
	}
	if loc.Column < 0 {
		loc.Column = 0
	}
	if loc.Length < 0 {
		loc.Length = 0
	}
	if loc.Column > endOfFirstLine {
		loc.Column = endOfFirstLine
	}
	if loc.Length > endOfFirstLine-loc.Column {
		loc.Length = endOfFirstLine - loc.Column
	}

	spacesPerTab := 2
	lineText := renderTabStops(firstLine, spacesPerTab)
	textUpToLoc := renderTabStops(firstLine[:loc.Column], spacesPerTab)
	markerStart := len(textUpToLoc)
	markerEnd := markerStart
	indent := strings.Repeat(" ", estimateWidthInTerminal(textUpToLoc))
	marker := "^"

	// Extend markers to cover the full range of the error
	if loc.Length > 0 {
		markerEnd = len(renderTabStops(firstLine[:loc.Column+loc.Length], spacesPerTab))
	}

	// Clip the marker to the bounds of the line
	if markerStart > len(lineText) {
		markerStart = len(lineText)
	}
	if markerEnd > len(lineText) {
		markerEnd = len(lineText)
	}
	if markerEnd < markerStart {
		markerEnd = markerStart
	}

	// Trim the line to fit the terminal width
	width := terminalInfo.Width
	if width < 1 {
		width = defaultTerminalWidth
	}
	width -= maxMargin + extraMarginChars
	if width < 1 {
		width = 1
	}
	if loc.Column == endOfFirstLine {
		// If the marker is at the very end of the line, the marker will be a "^"
		// character that extends one column past the end of the line. In this case
		// we should reserve a column at the end so the marker doesn't wrap.
		width -= 1
	}
	if len(lineText) > width {
		// Try to center the error
		sliceStart := (markerStart + markerEnd - width) / 2
		if sliceStart > markerStart-width/5 {
			sliceStart = markerStart - width/5
		}
		if sliceStart < 0 {
			sliceStart = 0
		}
		if sliceStart > len(lineText)-width {
			sliceStart = len(lineText) - width
		}
		sliceEnd := sliceStart + width

		// Slice the line
		slicedLine := lineText[sliceStart:sliceEnd]
		markerStart -= sliceStart
		markerEnd -= sliceStart
		if markerStart < 0 {
			markerStart = 0
		}
		if markerEnd > len(slicedLine) {
			markerEnd = len(slicedLine)
		}

		// Truncate the ends with "..."
		if len(slicedLine) > 3 && sliceStart > 0 {
			slicedLine = "..." + slicedLine[3:]
			if markerStart < 3 {
				markerStart = 3
			}
		}
		if len(slicedLine) > 3 && sliceEnd < len(lineText) {
			slicedLine = slicedLine[:len(slicedLine)-3] + "..."
			if markerEnd > len(slicedLine)-3 {
				markerEnd = len(slicedLine) - 3
			}
			if markerEnd < markerStart {
				markerEnd = markerStart
			}
		}

		// Now we can compute the indent
		lineText = slicedLine
		indent = strings.Repeat(" ", estimateWidthInTerminal(lineText[:markerStart]))
	}

	// If marker is still multi-character after clipping, make the marker wider
	if markerEnd-markerStart > 1 {
		marker = strings.Repeat("~", estimateWidthInTerminal(lineText[markerStart:markerEnd]))
	}

	// Put a margin before the marker indent
	margin := marginWithLineText(maxMargin, loc.Line)

	return MsgDetail{
		Path:   loc.File,
		Line:   loc.Line,
		Column: loc.Column,

		SourceBefore: margin + lineText[:markerStart],
		SourceMarked: lineText[markerStart:markerEnd],
		SourceAfter:  lineText[markerEnd:],

		Indent:     indent,
		Marker:     marker,
		Suggestion: loc.Suggestion,

		ContentAfter: afterFirstLine,
	}
}

// Estimate the number of columns this string will take when printed
func estimateWidthInTerminal(text string) int {
	// For now just assume each code point is one column. This is wrong but is
	// less wrong than assuming each code unit is one column.
	width := 0
	for text != "" {
		c, size := utf8.DecodeRuneInString(text)
		text = text[size:]

		// Ignore the Zero Width No-Break Space character (UTF-8 BOM)
		if c != 0xFEFF {
			width++
		}
	}
	return width
}

func renderTabStops(withTabs string, spacesPerTab int) string {
	if !strings.ContainsRune(withTabs, '\t') {
		return withTabs
	}

	withoutTabs := strings.Builder{}
	count := 0

	for _, c := range withTabs {
		if c == '\t' {
			spaces := spacesPerTab - count%spacesPerTab
			for i := 0; i < spaces; i++ {
				withoutTabs.WriteRune(' ')
				count++
			}
		} else {
			withoutTabs.WriteRune(c)
			count++
		}
	}

	return withoutTabs.String()
}

func (log Log) Add(kind MsgKind, tracker *LineColumnTracker, r Range, text string) {
	log.AddMsg(Msg{
		Kind: kind,
		Data: tracker.MsgData(r, text),
	})
}

func (log Log) AddWithNotes(kind MsgKind, tracker *LineColumnTracker, r Range, text string, notes []MsgData) {
	log.AddMsg(Msg{
		Kind:  kind,
		Data:  tracker.MsgData(r, text),
		Notes: notes,
	})
}
