// Copyright 2020-2026 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package printer

import (
	"strings"

	"github.com/bufbuild/protocompile/experimental/ast"
	"github.com/bufbuild/protocompile/experimental/dom"
	"github.com/bufbuild/protocompile/experimental/seq"
	"github.com/bufbuild/protocompile/experimental/token"
)

// gapStyle specifies the whitespace intent before a token.
type gapStyle int

const (
	gapNone gapStyle = iota
	gapSpace
	gapNewline
	gapSoftline      // gapSoftline inserts a space if the group is flat, or a newline if the group is broken
	gapBlankline     // gapBlankline inserts two newline characters
	gapInline        // gapInline acts like gapNone when there are no comments; when there are comments, it spaces around them
	gapPreserve      // gapPreserve is like gapNone but inherits the source's spacing around comments
	gapPreserveTight // gapPreserveTight is like gapPreserve but suppresses the inherited space before the first comment
)

// scopeKind distinguishes file-level scopes from body-level scopes,
// which have different gap rules.
type scopeKind int

const (
	scopeFile scopeKind = iota
	scopeBody
)

// PrintFile renders an [ast.File] to protobuf source text.
//
// Behavior is controlled by [Options]:
//
//   - [Options.Format] = false (round-trip mode): the file is
//     emitted as-is, with source whitespace and comments preserved
//     verbatim from token trivia.
//
//   - [Options.Format] = true: AST transforms and layout decisions
//     run. See [Formatting] for the configurable knobs; [Default]
//     and [Legacy] are ready-made presets.
//
// To apply mutations to the AST before rendering, use the companion
// [github.com/bufbuild/protocompile/experimental/ast/edit] package
// and pass the resulting file to PrintFile.
func PrintFile(options Options, file *ast.File) (string, error) {
	options = options.withDefaults()

	// In format mode, a file with no declarations and no comments
	// produces empty output. The dom renderer always appends a trailing
	// newline, so we short-circuit here.
	if options.Format && file.Decls().Len() == 0 {
		trivia := buildTriviaIndex(file.Stream())
		scope := trivia.scopeTrivia(0)
		if !triviaHasComments(scope) {
			return "", nil
		}
	}

	return dom.Render(options.domOptions(), func(push dom.Sink) {
		p := newPrinter(options, buildTriviaIndex(file.Stream()), push)
		p.printFile(file)
	}), nil
}

// Print renders a single declaration to protobuf source text as a
// snippet. The result has no trailing newline, leaving the caller to
// manage spacing between snippets.
//
// When decl is a top-level declaration, the output includes the
// detached leading trivia at its source position (e.g. a section
// comment that preceded it). Nested declarations (inside a message
// body, for example) emit their attached leading trivia only —
// Print has no broader file context for resolving the surrounding
// body scope's detached slot.
func Print(options Options, decl ast.DeclAny) string {
	options = options.withDefaults()
	domOpts := options.domOptions()

	// Explicitly set OmitTrailingNewline to true to avoid [dom] from padding the
	// output with a trailing newlines, allowing callers to manage the spacing of
	// the output.
	domOpts.OmitTrailingNewline = true
	return dom.Render(domOpts, func(push dom.Sink) {
		file := decl.Context()
		trivia := buildTriviaIndex(file.Stream())
		p := newPrinter(options, trivia, push)

		// Emit the decl's preceding scope slot for top-level decls so that section
		// comments at the decl's source position render alongside the decl. Nested
		// decls fall back to attached leading trivia only.
		if i, ok := fileDeclIndex(file, decl); ok {
			p.emitTriviaSlot(trivia.scopeTrivia(0), i)
		}
		p.printDecl(decl, gapNewline)

		// If non-format mode left any trailing trivia in pending after the last
		// token, flush it.
		p.emitTrivia(gapNone)
	})
}

// fileDeclIndex returns the index of decl in file.Decls() at the top
// level, or false if decl is not a top-level declaration.
func fileDeclIndex(file *ast.File, decl ast.DeclAny) (int, bool) {
	decls := file.Decls()
	for i := range decls.Len() {
		if decls.At(i) == decl {
			return i, true
		}
	}
	return 0, false
}

// printer tracks state for printing AST nodes with fidelity.
type printer struct {
	options Options
	trivia  *triviaIndex
	pending []token.Token
	push    dom.Sink

	// indent is the cached indentation string applied by [printer.withIndent],
	// computed once from [Formatting.TabstopWidth] at construction time to avoid
	// re-allocating on every nested scope.
	indent string

	// ctx stores expected formatting behaviours based on the scope of the
	// printed entity.
	ctx context

	// declSourceIdx maps a sorted-iteration index to the original
	// source-order index for the file's top-level decls. Set during
	// printFile when CanonicalizeFileOrder reorders decls; nil at
	// other times (in which case sorted index = source index).
	// Consulted by [printer.declGap] to look up the right
	// [detachedTrivia.hasBlankBefore] entry.
	declSourceIdx []int
}

// newPrinter constructs a printer for the given options, trivia index,
// and dom sink. options should already have defaults applied via
// [Options.withDefaults].
func newPrinter(options Options, trivia *triviaIndex, push dom.Sink) *printer {
	return &printer{
		options: options,
		trivia:  trivia,
		push:    push,
		indent:  strings.Repeat(" ", options.Formatting.TabstopWidth),
	}
}

// printFile prints all declarations in a file, zipping with trivia slots.
func (p *printer) printFile(file *ast.File) {
	trivia := p.trivia.scopeTrivia(0)
	decls := seq.Indexer[ast.DeclAny](file.Decls())

	if p.options.Format && p.options.Formatting.CanonicalizeFileOrder {
		sourceOrder := seq.ToSlice(decls)
		// Build a lookup from each decl to its source-order index
		// before sorting. After sortFileDeclsForFormat, the iteration
		// order changes but trivia.hasBlankBefore is keyed by source
		// position, so we need to translate sorted index -> source
		// index when consulting it. Synthetic decls (no source span)
		// map to -1 so they do not consume a slot in blankBefore.
		srcIdxByDecl := make(map[ast.DeclAny]int, len(sourceOrder))
		natCounter := 0
		for _, d := range sourceOrder {
			if d.Span().IsZero() {
				srcIdxByDecl[d] = -1
				continue
			}
			srcIdxByDecl[d] = natCounter
			natCounter++
		}
		sorted := append([]ast.DeclAny(nil), sourceOrder...)
		sortFileDeclsForFormat(sorted)
		p.declSourceIdx = make([]int, len(sorted))
		for i, d := range sorted {
			if idx, ok := srcIdxByDecl[d]; ok {
				p.declSourceIdx[i] = idx
			} else {
				p.declSourceIdx[i] = -1
			}
		}
		defer func() { p.declSourceIdx = nil }()
		decls = seq.NewFunc(len(sorted), func(i int) ast.DeclAny {
			return sorted[i]
		})
	}

	p.printScopeDecls(trivia, decls, scopeFile)
	// In format mode, trailing file comments need a newline gap so they
	// don't run into the last declaration's closing token. But if there
	// are no declarations at all, emit nothing (empty file = empty output).
	endGap := gapNone
	if p.options.Format {
		if decls.Len() > 0 || p.pendingHasComments() {
			endGap = gapNewline
			// If the last declaration's trailing trivia had a blank
			// line before the remaining tokens, preserve it for EOF
			// comments separated from the last declaration.
			if trivia.blankBeforeClose && p.pendingHasComments() {
				endGap = gapBlankline
			}
		}
	}

	p.emitTrivia(endGap)
}

// pendingHasComments reports whether pending contains comments.
func (p *printer) pendingHasComments() bool {
	return sliceHasComment(p.pending)
}

// printToken emits a token with its trivia.
func (p *printer) printToken(tok token.Token, gap gapStyle) {
	if tok.IsZero() {
		return
	}
	p.printTokenAs(tok, gap, tok.Text())
}

// printTokenAs prints a token using replacement text instead of the token's
// own text. This is used for normalizing delimiters (e.g., angle brackets
// to curly braces) while preserving the token's attached trivia.
func (p *printer) printTokenAs(tok token.Token, gap gapStyle, text string) {
	att, hasTrivia := p.trivia.tokenTrivia(tok.ID())
	if hasTrivia {
		p.appendPending(att.leading)
	}

	if len(text) > 0 {
		if hasTrivia {
			if !p.options.Format {
				// In non-format mode, ignore caller's gap and print trivia as-is,
				// which may or may not include whitespace.
				gap = gapNone
			}
			p.emitTrivia(gap)
		} else {
			p.emitGap(gap)
		}

		p.push(dom.Text(text))
	}

	if hasTrivia {
		p.emitTrailing(att.trailing)
	}
}

// emitTrailing emits trailing attached trivia for a token.
func (p *printer) emitTrailing(trailing []token.Token) {
	if len(trailing) == 0 {
		return
	}

	if p.options.Format {
		rewriteToBlock := p.options.Formatting.RewriteTrailingLineCommentsToBlock
		blockOnNewLine := p.options.Formatting.TrailingBlockCommentsOnNewLine && p.ctx.trailingBlockOnNewLine
		for _, t := range trailing {
			if t.Kind() == token.Comment {
				isLine := strings.HasPrefix(t.Text(), "//")
				// Choose the gap before the comment. Block comments
				// in a vertical scope go on their own line; everything
				// else gets a single space.
				if !isLine && blockOnNewLine {
					p.push(tagNewline)
				} else {
					p.push(tagSpace)
				}
				switch {
				case rewriteToBlock && p.ctx.lineToBlock && isLine:
					// Convert // comment to /* comment */ for inline contexts.
					body := strings.TrimPrefix(strings.TrimRight(t.Text(), " \t"), "//")
					// Escape any `*/` in the body to `* /` so it
					// cannot prematurely terminate the synthesized
					// block comment. This is a correctness divergence
					// from the legacy formatter, which skips the
					// escape and produces invalid output for bodies
					// containing `*/` (e.g. `// foo */ bar` becomes
					// `/* foo */ bar */`, parsed as a comment plus
					// leaked text `bar */`). Exercised by
					// TestFormat/compact_options.proto, with_terminator.
					body = strings.ReplaceAll(body, "*/", "* /")
					p.push(dom.Text("/*" + body + " */"))
				case !rewriteToBlock && p.ctx.lineToBlock && isLine:
					// Verbatim emission inside a context where inline
					// content (e.g. `;`, `]`, `.next`) follows. The
					// `//` would consume that content, so push a
					// newline after the comment to force a layout
					// break. dom merges this with any adjacent newline
					// from the surrounding broken layout.
					p.emitComment(t)
					p.push(tagNewline)
				default:
					p.emitComment(t)
				}
			}
		}
	} else {
		p.pending = append(p.pending, trailing...)
	}
}

// emitCommaTrivia emits trailing trivia from a comma token that is not
// itself printed (e.g., commas elided from message literal fields in
// format mode). This ensures comments attached to skipped commas are
// never lost.
func (p *printer) emitCommaTrivia(comma token.Token) {
	if comma.IsZero() {
		return
	}

	att, ok := p.trivia.tokenTrivia(comma.ID())
	if !ok {
		return
	}

	p.emitTrailing(att.trailing)
}

// appendPending buffers trivia tokens for later processing by emitTrivia.
func (p *printer) appendPending(tokens []token.Token) {
	p.pending = append(p.pending, tokens...)
}

// printScopeDecls prints declarations in a scope, computing
// inter-declaration gaps and emitting trivia slots between them.
//
// Detached trivia (scope.slots[i]) stays at scope positions so that
// section-boundary comments do not travel with their declaration when
// the file is sorted in format mode.
//
// Attached trivia (leading on first token, trailing on last) is handled by
// [printer.printDecl] via [printer.printTokenAs].
//
// AST decls may include synthetic decls (those without a source span,
// e.g. inserted via the edit package). Synthetic decls do not consume
// entries in trivia.slots or trivia.blankBefore, so iteration indices
// are translated through scopeSourceIdx before consulting trivia.
func (p *printer) printScopeDecls(
	trivia detachedTrivia,
	decls seq.Indexer[ast.DeclAny],
	scope scopeKind,
) {
	sourceIdx := p.scopeSourceIdx(decls, scope)
	lastSrc := -1
	for i := range decls.Len() {
		src := sourceIdx[i]
		if src >= 0 {
			for s := lastSrc + 1; s <= src; s++ {
				p.emitTriviaSlot(trivia, s)
			}
			if src > lastSrc {
				lastSrc = src
			}
		}
		gap := p.declGap(decls, trivia, i, src, scope)
		p.printDecl(decls.At(i), gap)
	}

	p.emitRemainingTrivia(trivia, lastSrc+1)
}

// scopeSourceIdx returns a slice mapping each AST decl iteration index
// to its source decl index, or -1 if the decl is synthetic (has no
// source span). For the file scope under CanonicalizeFileOrder, the
// pre-computed mapping from printFile is reused.
func (p *printer) scopeSourceIdx(decls seq.Indexer[ast.DeclAny], scope scopeKind) []int {
	n := decls.Len()
	if scope == scopeFile && p.declSourceIdx != nil && len(p.declSourceIdx) == n {
		return p.declSourceIdx
	}
	out := make([]int, n)
	counter := 0
	for i := range n {
		if decls.At(i).Span().IsZero() {
			out[i] = -1
			continue
		}
		out[i] = counter
		counter++
	}
	return out
}

// declGap computes the gap before declaration i in a scope.
//
// For the first declaration (i==0), it handles flushing detached leading
// comments (copyright headers at file level, or comments between '{'
// and the first member at body level). For subsequent declarations, it
// determines whether a blank line or regular newline separates them.
//
// srcIdx is the source decl index for decls.At(i), or -1 if the decl
// is synthetic (no source span). Synthetic decls never consult
// trivia.hasBlankBefore because they have no source position to
// compare against.
func (p *printer) declGap(
	decls seq.Indexer[ast.DeclAny],
	trivia detachedTrivia,
	i, srcIdx int,
	scope scopeKind,
) gapStyle {
	if i == 0 {
		return p.firstDeclGap(scope)
	}

	if !p.options.Format {
		return gapNewline
	}

	// File level: the legacy formatter adds blank lines around the
	// "header sections" (imports, options) and after `syntax` —
	// otherwise transitions preserve source adjacency.
	//
	// Specifically auto-blank when:
	//   - entering the imports or options section (the visually
	//     distinct header groupings), or
	//   - leaving syntax for anything other than package (so
	//     `syntax = ""; message M {}` still gets a header gap).
	//
	// Within sorted sections (imports, file-level options),
	// canonicalization reorders entries, so hasBlankBefore (keyed
	// by source position) doesn't line up with the sorted output;
	// suppress source blanks there.
	if scope == scopeFile {
		prev, curr := rankDecl(decls.At(i-1)), rankDecl(decls.At(i))
		if prev != curr && (curr == rankImport || curr == rankOption) {
			return gapBlankline
		}
		if prev == rankSyntax && curr != rankPackage {
			return gapBlankline
		}
		if curr == rankImport || curr == rankOption {
			return gapNewline
		}
		if srcIdx >= 0 && trivia.hasBlankBefore(srcIdx) {
			return gapBlankline
		}
		return gapNewline
	}

	// Body level: preserve blank lines from the original source.
	if srcIdx >= 0 && trivia.hasBlankBefore(srcIdx) {
		return gapBlankline
	}

	return gapNewline
}

// firstDeclGap computes the gap before the first declaration in a scope.
func (p *printer) firstDeclGap(scope scopeKind) gapStyle {
	if !p.options.Format {
		if scope == scopeFile {
			return gapNone
		}
		return gapNewline
	}

	// We deliberately do not flush p.pending here. The slot trivia
	// already in pending (file-leading comments, or body comments
	// between '{' and the first member) needs to merge with the first
	// declaration's leading trivia in a single emitTrivia pass so that
	// blank-line counts (newlineRun) span the whole boundary; otherwise
	// splitDetached's separation of "last \n on the next token's leading"
	// from the rest of the run would fragment a blank line into two
	// flushes and lose it. The natural gap returned here propagates into
	// printTokenAs, which appends att.leading to pending and then calls
	// emitTrivia, seeing all the trivia together.
	if scope == scopeFile {
		return gapNone
	}

	return gapNewline
}

// emitTriviaSlot appends the detached trivia for slot[i] to pending.
// In format mode, whitespace tokens are filtered via appendPending.
func (p *printer) emitTriviaSlot(trivia detachedTrivia, i int) {
	if i >= len(trivia.slots) {
		return
	}

	p.appendPending(trivia.slots[i])
}

// emitRemainingTrivia emits the remaining detached trivia for slot >= i.
func (p *printer) emitRemainingTrivia(trivia detachedTrivia, i int) {
	for ; i < len(trivia.slots); i++ {
		p.emitTriviaSlot(trivia, i)
	}
}

// emitGap pushes whitespace tags for the given gap style.
func (p *printer) emitGap(gap gapStyle) {
	switch gap {
	case gapSpace:
		p.push(tagSpace)
	case gapNewline:
		p.push(tagNewline)
	case gapSoftline:
		softline(p.push)
	case gapBlankline:
		blankline(p.push)
	case gapInline, gapPreserve, gapPreserveTight:
		// No visual gap. Comment handling is done in emitTrivia.
	}
}

// commentGap returns the appropriate gap for comment separation.
func commentGap(contextGap gapStyle, isLineComment bool, blankRun int) gapStyle {
	if blankRun >= 2 {
		return gapBlankline
	}

	if isLineComment {
		return gapNewline
	}

	return contextGap
}

// emitTrivia flushes pending trivia. In format mode, only comments
// are emitted with canonical spacing; in non-format mode, all tokens
// are concatenated verbatim.
func (p *printer) emitTrivia(gap gapStyle) {
	if !p.options.Format {
		if len(p.pending) > 0 {
			var buf strings.Builder
			for _, tok := range p.pending {
				buf.WriteString(tok.Text())
			}
			p.push(dom.Text(buf.String()))
			p.pending = p.pending[:0]
		}
		return
	}

	afterGap := gapSoftline
	switch gap {
	case gapSpace:
		afterGap = gapSpace
	case gapPreserve, gapPreserveTight:
		// afterGap is determined dynamically per gap point based on
		// whether the source had whitespace.
		afterGap = gapNone
	case gapInline:
		// gapInline is used for punctuation tokens (`;`, `,`) where
		// comments should have a space before the first and no gap after
		// the last, keeping the punctuation on the same line.
		afterGap = gapNone
	}

	firstGap := gap
	if gap == gapInline {
		firstGap = gapSpace
	}

	// inheritGap promotes base to gapSpace when the source had
	// non-newline whitespace in a gapPreserve/gapPreserveTight context.
	inheritGap := func(base gapStyle, hasSpace bool) gapStyle {
		if (gap == gapPreserve || gap == gapPreserveTight) && hasSpace {
			return gapSpace
		}
		return base
	}

	hasComment := false
	prevIsLine := false
	newlineRun := 0
	hasNonNewlineSpace := false
	// preCommentNewline tracks whether a newline appeared BEFORE the
	// first comment in pending. Pair-leading-block needs this: the
	// legacy formatter pairs `/* Before */ element` only when the
	// comment sat on its own line in source (newline before it), not
	// when the comment came directly after an open bracket on the
	// same line.
	preCommentNewline := false
	for _, tok := range p.pending {
		if tok.Kind() == token.Space {
			// A space token's text may combine newlines and spaces
			// (e.g. "\n  " for a newline followed by indent), so
			// count newlines and detect non-newline whitespace
			// independently rather than expecting a bare "\n".
			text := tok.Text()
			if n := strings.Count(text, "\n"); n > 0 {
				newlineRun += n
				if !hasComment {
					preCommentNewline = true
				}
			}
			if strings.TrimLeft(text, "\n") != "" {
				hasNonNewlineSpace = true
			}
			continue
		}
		if tok.Kind() != token.Comment {
			continue
		}

		fg := inheritGap(firstGap, hasNonNewlineSpace)
		ag := inheritGap(afterGap, hasNonNewlineSpace)

		// Suppress the inherited space before the first comment when
		// gapPreserveTight is used (right after an open bracket).
		if gap == gapPreserveTight && !hasComment {
			fg = firstGap
		}

		if !hasComment {
			p.emitGap(fg)
		} else {
			p.emitGap(commentGap(ag, prevIsLine, newlineRun))
		}

		hasNonNewlineSpace = false
		newlineRun = 0
		isLine := strings.HasPrefix(tok.Text(), "//")
		p.emitComment(tok)
		hasComment = true
		prevIsLine = isLine
	}
	p.pending = p.pending[:0]

	if hasComment {
		// When the surrounding scope wants leading block comments
		// paired with their element, override the post-comment gap
		// to gapSpace so the element follows on the same line.
		// Conditions:
		//   - gap == gapNewline: only fire on element-leading
		//     positions (broken-scope per-element gap), not on
		//     punctuation gaps like gapInline (commas, colons).
		//   - !prevIsLine: line comments must end with a newline.
		//   - newlineRun == 0: comment and element on same line
		//     in source.
		//   - preCommentNewline: comment sat on its own line in
		//     source (newline before it). Without this, the legacy
		//     formatter separates the comment from the element
		//     even when they share a line in a source-flat array.
		if gap == gapNewline &&
			!prevIsLine &&
			newlineRun == 0 &&
			preCommentNewline &&
			p.options.Formatting.PairLeadingBlockComments &&
			p.ctx.pairLeadingBlock {
			p.emitGap(gapSpace)
			return
		}
		// Compute post-comment gap. When the surrounding gap is
		// gapNewline (broken-element position) and the source had
		// at least one newline between the last block comment and
		// the next token, preserve that newline — otherwise the
		// inheritGap promotion to gapSpace would inline content the
		// user wrote on separate lines.
		finalGap := commentGap(inheritGap(afterGap, hasNonNewlineSpace), prevIsLine, newlineRun)
		if gap == gapNewline && !prevIsLine && newlineRun >= 1 && finalGap == gapSpace {
			finalGap = gapNewline
		}
		p.emitGap(finalGap)
		return
	}

	p.emitGap(gap)
}

// extractCloseComments checks if a close token (], }) has leading
// comments in its trivia. Returns the comments and the full trivia
// so the caller can suppress the default printToken and emit the
// comments inside an indented block instead.
func (p *printer) extractCloseComments(closeTok token.Token) ([]token.Token, attachedTrivia) {
	if !p.options.Format {
		return nil, attachedTrivia{}
	}

	att, hasTrivia := p.trivia.tokenTrivia(closeTok.ID())
	if !hasTrivia {
		return nil, attachedTrivia{}
	}

	if sliceHasComment(att.leading) {
		return att.leading, att
	}

	return nil, attachedTrivia{}
}

// extractOpenTrailing returns the trailing trivia for a token if it
// contains comments, or nil otherwise. Used to detect trailing comments
// on open brackets that need to be moved inside an indented block.
func (p *printer) extractOpenTrailing(tok token.Token) []token.Token {
	att, ok := p.trivia.tokenTrivia(tok.ID())
	if !ok {
		return nil
	}

	if sliceHasComment(att.trailing) {
		return att.trailing
	}
	return nil
}

// emitCloseTok emits a close token, respecting pre-extracted close
// comments. When close comments were extracted (and emitted inside the
// preceding indent block), the token text is emitted directly with its
// trailing trivia. Otherwise, printToken/printTokenAs handles it normally.
func (p *printer) emitCloseTok(closeTok token.Token, closeText string, closeComments []token.Token, closeAtt attachedTrivia) {
	if len(closeComments) > 0 {
		p.emitGap(gapNewline)
		p.push(dom.Text(closeText))
		p.emitTrailing(closeAtt.trailing)
	} else {
		p.printTokenAs(closeTok, gapNewline, closeText)
	}
}

// scopeHasAttachedComments checks whether any token in a fused scope
// (brackets, braces, parens) has attached comments (leading or trailing).
func (p *printer) scopeHasAttachedComments(fused token.Token) bool {
	if p.trivia == nil {
		return false
	}

	openTok, closeTok := fused.StartEnd()
	// Check open token trailing.
	if att, ok := p.trivia.tokenTrivia(openTok.ID()); ok {
		if sliceHasComment(att.trailing) {
			return true
		}
	}

	// Check close token leading.
	if att, ok := p.trivia.tokenTrivia(closeTok.ID()); ok {
		if sliceHasComment(att.leading) {
			return true
		}
	}

	// Check interior tokens.
	cursor := fused.Children()
	for tok := cursor.NextSkippable(); !tok.IsZero(); tok = cursor.NextSkippable() {
		if tok.Kind().IsSkippable() {
			continue
		}
		if att, ok := p.trivia.tokenTrivia(tok.ID()); ok {
			if sliceHasComment(att.leading) || sliceHasComment(att.trailing) {
				return true
			}
		}
	}
	return false
}

// scopeHasLineTrailingComments reports whether any token in a fused
// scope has a trailing `//` line comment. Used by the layout fallback
// when [Formatting.RewriteTrailingLineCommentsToBlock] is false: a `//`
// trailing in an otherwise-flat scope would consume the closing
// punctuation, so the scope must render broken instead.
func (p *printer) scopeHasLineTrailingComments(fused token.Token) bool {
	if p.trivia == nil {
		return false
	}

	hasLineComment := func(tokens []token.Token) bool {
		for _, t := range tokens {
			if t.Kind() == token.Comment && strings.HasPrefix(t.Text(), "//") {
				return true
			}
		}
		return false
	}

	openTok, closeTok := fused.StartEnd()
	if att, ok := p.trivia.tokenTrivia(openTok.ID()); ok {
		if hasLineComment(att.trailing) {
			return true
		}
	}
	if att, ok := p.trivia.tokenTrivia(closeTok.ID()); ok {
		if hasLineComment(att.leading) {
			return true
		}
	}
	cursor := fused.Children()
	for tok := cursor.NextSkippable(); !tok.IsZero(); tok = cursor.NextSkippable() {
		if tok.Kind().IsSkippable() {
			continue
		}
		if att, ok := p.trivia.tokenTrivia(tok.ID()); ok {
			if hasLineComment(att.leading) || hasLineComment(att.trailing) {
				return true
			}
		}
	}
	return false
}

// scopeHasUninlineableLeadingComments reports whether any interior token
// in a fused scope has a leading comment that prevents inline rendering.
//
// A `//` line comment in leading trivia is always uninlineable (it would
// eat the rest of the line). A `/* */` block comment is uninlineable
// only when it is preceded by a newline in the same leading run, since
// the newline indicates the source had the comment on its own line.
// Block comments mid-expression (e.g. `= /* before */ value`) have no
// preceding newline and stay inline cleanly.
func (p *printer) scopeHasUninlineableLeadingComments(fused token.Token) bool {
	if p.trivia == nil {
		return false
	}

	cursor := fused.Children()
	for tok := cursor.NextSkippable(); !tok.IsZero(); tok = cursor.NextSkippable() {
		if tok.Kind().IsSkippable() {
			continue
		}
		att, ok := p.trivia.tokenTrivia(tok.ID())
		if !ok {
			continue
		}
		sawNewline := false
		for _, t := range att.leading {
			if t.Kind() == token.Space {
				if strings.Contains(t.Text(), "\n") {
					sawNewline = true
				}
				continue
			}
			if t.Kind() != token.Comment {
				continue
			}
			if strings.HasPrefix(t.Text(), "//") || sawNewline {
				return true
			}
		}
	}
	return false
}

// semiGap returns the gap to use before a semicolon or comma.
// In format mode, uses gapInline to keep comments on the same line as
// the preceding token. In non-format mode, uses gapNone.
func (p *printer) semiGap() gapStyle {
	if p.options.Format {
		return gapInline
	}
	return gapNone
}

// withIndent runs fn with an indented printer, swapping the sink temporarily.
func (p *printer) withIndent(fn func(p *printer)) {
	originalPush := p.push
	p.push(dom.Indent(p.indent, func(indentSink dom.Sink) {
		p.push = indentSink
		fn(p)
	}))
	p.push = originalPush
}

// withGroup runs fn with a grouped printer, swapping the sink temporarily.
func (p *printer) withGroup(fn func(p *printer)) {
	originalPush := p.push
	p.push(dom.Group(p.options.Formatting.MaxWidth, func(groupSink dom.Sink) {
		p.push = groupSink
		fn(p)
	}))
	p.push = originalPush
}

// emitComment trims trailing whitespace from a comment token and emits
// it, dispatching to emitBlockComment for /* comments.
func (p *printer) emitComment(tok token.Token) {
	text := strings.TrimRight(tok.Text(), " \t")
	if strings.HasPrefix(text, "/*") {
		p.emitBlockComment(text)
	} else {
		p.push(dom.Text(text))
	}
}

// emitBlockComment normalizes and emits a multi-line block comment as
// separate dom.Text calls so that dom.Indent can apply outer indentation
// to each line.
//
// Single-line block comments (e.g., /* foo */) are emitted as-is.
// Multi-line comments where the closing line has content before */ are
// treated as degenerate and emitted verbatim.
//
// When [Formatting.NormalizeBlockComments] is false, lines are emitted
// verbatim (with trailing whitespace per line stripped for cleanliness)
// rather than rewritten to a canonical prefix or plain style.
//
// The normalization algorithm matches the legacy formatter's behavior:
//   - Detect if all non-empty interior lines share a common non-alphanumeric
//     prefix character (e.g., *, =). If so, strip all whitespace and re-add
//     " " before each line (prefix style). If the prefix is *, the closing
//     line becomes " */".
//   - Otherwise (plain style), compute the minimum visual indentation of
//     non-empty interior lines, unindent by that amount, then add "   "
//     (3 spaces) before each line.
func (p *printer) emitBlockComment(text string) {
	lines := strings.Split(text, "\n")
	if len(lines) <= 1 {
		p.push(dom.Text(text))
		return
	}

	if !p.options.Formatting.NormalizeBlockComments {
		// Verbatim emission preserves the user's prefix style and
		// relative line content, but must strip the common leading
		// indent shared by all interior lines so that dom.Indent's
		// outer-indent prefix does not compound on each re-format
		// pass. Each line is pushed separately with an explicit "\n"
		// between so dom.Indent prefixes the outer indentation onto
		// each line.
		minIndent := -1
		for i := 1; i < len(lines); i++ {
			if strings.TrimLeft(lines[i], " \t") == "" {
				continue
			}
			indent := computeVisualIndent(lines[i])
			if minIndent < 0 || indent < minIndent {
				minIndent = indent
			}
		}
		if minIndent < 0 {
			minIndent = 0
		}
		p.push(dom.Text(strings.TrimRight(lines[0], " \t")))
		for i := 1; i < len(lines); i++ {
			line := unindent(lines[i], minIndent)
			line = strings.TrimRight(line, " \t")
			p.push(dom.Text("\n"))
			p.push(dom.Text(line))
		}
		return
	}

	// Determine whether the last line is a standalone closing "*/" or
	// contains content before it (e.g., "  buzz */").
	lastTrimmed := strings.TrimLeft(lines[len(lines)-1], " \t")
	standaloneClose := strings.HasPrefix(lastTrimmed, "*/") && strings.TrimRight(lastTrimmed, " \t") == "*/"

	// Compute minimum indent and detect prefix character across all
	// lines after the first (interior + closing).
	minIndent := -1
	var prefix byte
	prefixSet := false
	for i := 1; i < len(lines); i++ {
		trimmed := strings.TrimLeft(lines[i], " \t")
		if trimmed == "" || trimmed == "*/" {
			continue
		}

		indent := computeVisualIndent(lines[i])
		if minIndent < 0 || indent < minIndent {
			minIndent = indent
		}

		ch := trimmed[0]
		if isCommentPrefix(ch) {
			if !prefixSet {
				prefix = ch
				prefixSet = true
			} else if ch != prefix {
				prefix = 0
			}
		} else {
			prefix = 0
		}
	}

	if minIndent < 0 {
		minIndent = 0
	}

	// Emit first line.
	p.push(dom.Text(strings.TrimRight(lines[0], " \t")))

	// Process lines 1..N-1 (interior lines, and closing if not standalone).
	end := len(lines) - 1
	if !standaloneClose {
		// Closing line has content; process it like any other line.
		end = len(lines)
	}

	// pendingNewlines accumulates the number of '\n's that should
	// precede the next content push. We coalesce them into a single
	// wider break tag so blank interior lines survive dom's adjacent-
	// break merge (which keeps the wider of two equal-width breaks but
	// drops one when they are equal).
	pendingNewlines := 0
	for i := 1; i < end; i++ {
		pendingNewlines++
		trimmed := strings.TrimLeft(lines[i], " \t")

		if trimmed == "" {
			continue
		}

		var content string
		if prefix != 0 {
			content = " " + strings.TrimRight(trimmed, " \t")
		} else {
			line := unindent(lines[i], minIndent)
			line = strings.TrimRight(line, " \t")
			if line == "" {
				continue
			}
			content = "   " + line
		}

		p.push(dom.Text(strings.Repeat("\n", pendingNewlines)))
		p.push(dom.Text(content))
		pendingNewlines = 0
	}

	// Emit standalone closing line if applicable.
	if standaloneClose {
		pendingNewlines++
		p.push(dom.Text(strings.Repeat("\n", pendingNewlines)))
		if prefix == '*' {
			p.push(dom.Text(" */"))
		} else {
			p.push(dom.Text("*/"))
		}
	}
}

// isCommentPrefix reports whether ch is a valid block comment line prefix
// character. Only printable ASCII punctuation qualifies (e.g., *, =, -, #).
// Letters, digits, whitespace, and control characters are not valid prefixes.
func isCommentPrefix(ch byte) bool {
	return ch >= '!' && ch <= '~' &&
		!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9'))
}

// computeVisualIndent returns the visual indentation of a line, expanding
// tabs to 8-column tab stops (matching the legacy formatter's behavior).
func computeVisualIndent(line string) int {
	indent := 0
	for _, r := range line {
		switch r {
		case ' ':
			indent++
		case '\t':
			indent += 8 - (indent % 8)
		default:
			return indent
		}
	}
	return indent
}

// unindent removes up to n visual columns of leading whitespace from line,
// expanding tabs to 8-column tab stops.
func unindent(line string, n int) string {
	pos := 0
	for i, r := range line {
		if pos == n {
			return line[i:]
		}
		if pos > n {
			// Tab stop overshot; add back spaces to compensate.
			return strings.Repeat(" ", pos-n) + line[i:]
		}
		switch r {
		case ' ':
			pos++
		case '\t':
			pos += 8 - (pos % 8)
		default:
			return line[i:]
		}
	}
	return ""
}
