package lsp

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// wholeFileEnd returns the (line, character) position of the end of
// the file at absPath, suitable for a full-file LSP Range. Position
// is 0-based and exclusive — line N where the file has N+1 lines, or
// line N character C when the last line has C characters before EOL.
//
// gopls (and other strict servers) reject `Position{Line: 1<<30}` as
// "line number out of range"; this helper returns a real bound so the
// fix-all loop survives strict validators. On stat / read errors we
// fall back to a bounded-but-still-large sentinel — better to ask for
// "the first ~1M lines" than to fail the whole pass on a transient
// read error.
func wholeFileEnd(absPath string) (line, character int) {
	data, err := os.ReadFile(absPath)
	if err != nil {
		return 1_000_000, 0
	}
	if len(data) == 0 {
		return 0, 0
	}
	// Trailing newline → last "line" is empty. LSP positions are
	// 0-based and an "End" pointing at the start of an empty line N
	// is the canonical way to span everything before it.
	lineCount := bytes.Count(data, []byte{'\n'})
	lastNL := bytes.LastIndexByte(data, '\n')
	tail := data[lastNL+1:]
	if len(tail) == 0 {
		return lineCount, 0
	}
	return lineCount, len(tail)
}

// FixAllOptions controls FixAllInFile behaviour.
type FixAllOptions struct {
	// AbsPath is the absolute path to the file under repair.
	AbsPath string
	// Kinds restricts which CodeAction kinds are eligible. Empty
	// defaults to {quickfix, source.organizeImports}.
	Kinds []string
	// MaxIterations bounds how many round-trips the loop performs;
	// each iteration applies at most one batch of actions and
	// re-collects diagnostics. Zero defaults to 5.
	MaxIterations int
	// MaxActionsPerIter caps the number of actions applied per
	// iteration. Zero defaults to 50.
	MaxActionsPerIter int
	// DiagnosticTimeout is how long to wait for the server to publish
	// updated diagnostics after each apply. Zero defaults to 5s.
	DiagnosticTimeout time.Duration
}

// FixAllResult summarises a fix-all run.
type FixAllResult struct {
	// Iterations is the number of apply/re-diagnose cycles run.
	Iterations int
	// AppliedActions is the total number of actions written to disk.
	AppliedActions int
	// FilesTouched lists the absolute paths whose contents changed.
	FilesTouched []string
	// FinalDiagnostics is the diagnostics list after the last apply.
	FinalDiagnostics []Diagnostic
}

// FixAllInFile loops codeAction → apply → re-collect-diagnostics until
// no further changes are produced or the iteration cap is reached.
// Modelled on agent-lsp's `/lsp-fix-all` workflow.
//
// The provider must have been initialised (EnsureClient + the file
// opened — FixAllInFile will open the file itself if necessary).
//
// FixAllInFile is conservative: when a CodeAction has no Edit and
// only a Command (legacy) form, the command is *not* executed by
// default — most servers route those through workspace/applyEdit
// which Gortex doesn't follow up on by default. Enable explicit
// command execution by setting FixAllOptions.Kinds to include
// "source.fixAll" or by calling ExecuteCommand directly.
func (p *Provider) FixAllInFile(opts FixAllOptions) (*FixAllResult, error) {
	if p.client == nil {
		return nil, errors.New("LSP client not initialized")
	}
	if opts.AbsPath == "" {
		return nil, errors.New("AbsPath is required")
	}
	if opts.MaxIterations <= 0 {
		opts.MaxIterations = 5
	}
	if opts.MaxActionsPerIter <= 0 {
		opts.MaxActionsPerIter = 50
	}
	if opts.DiagnosticTimeout <= 0 {
		opts.DiagnosticTimeout = 5 * time.Second
	}
	kinds := opts.Kinds
	if len(kinds) == 0 {
		kinds = []string{
			CodeActionKindQuickFix,
			CodeActionKindSourceOrganizeImports,
		}
	}

	// Open if needed.
	repoRoot := r0(p.spec, opts.AbsPath)
	if err := p.openDocument(repoRoot, relForOpen(repoRoot, opts.AbsPath)); err != nil && !errors.Is(err, os.ErrNotExist) {
		// Already-open is a no-op; only abort on non-trivial errors.
		return nil, fmt.Errorf("open document: %w", err)
	}

	result := &FixAllResult{}
	touched := make(map[string]bool)

	for iter := 0; iter < opts.MaxIterations; iter++ {
		result.Iterations = iter + 1
		// Snapshot diagnostics for this iteration.
		diags, _ := p.LastDiagnostics(opts.AbsPath)

		// Build the request — full-file range, restricted to the
		// caller's allowed kinds. We can't pass an unbounded sentinel
		// (1 << 30 etc.) because gopls and a few other servers
		// validate End.Line against the file's real line count and
		// reject with `line number N out of range 0-M`. Read the
		// file's actual line count once per iteration so re-edits
		// during fix-all stay in sync.
		endLine, endChar := wholeFileEnd(opts.AbsPath)
		req := CodeActionsRequest{
			AbsPath:     opts.AbsPath,
			Range:       Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}},
			Diagnostics: diags,
			Only:        kinds,
		}
		actions, err := p.GetCodeActions(req)
		if err != nil {
			return result, fmt.Errorf("get code actions: %w", err)
		}
		// Sort: preferred → by kind → by title — keeps results
		// deterministic across servers.
		sortActions(actions)

		applied := 0
		for _, a := range actions {
			if applied >= opts.MaxActionsPerIter {
				break
			}
			if a.Disabled != nil {
				continue
			}
			edit := a.Edit
			if edit == nil && a.Data != nil {
				resolved, err := p.ResolveCodeAction(a)
				if err == nil {
					edit = resolved.Edit
				}
			}
			if edit == nil {
				// Legacy Command-form. Skip unless it's a source.* kind
				// where the server commits the changes itself.
				if a.IsCommand() && (a.Kind == CodeActionKindSourceOrganizeImports || a.Kind == CodeActionKindSourceFixAll) {
					_, _ = p.ExecuteCommand(Command{Command: a.Command, Arguments: a.Arguments, Title: a.Title})
					applied++
				}
				continue
			}
			files, err := WriteWorkspaceEdit(*edit)
			if err != nil {
				return result, fmt.Errorf("apply workspace edit: %w", err)
			}
			for _, f := range files {
				touched[f] = true
				_ = p.changeDocument(f, mustReadFile(f))
			}
			applied++
		}
		result.AppliedActions += applied
		if applied == 0 {
			// No work this iteration — converge.
			break
		}
		// Wait for the server to refresh diagnostics so the next
		// iteration sees the new state. Best-effort; if no waiter
		// fires we move on with the cached snapshot.
		_ = p.WaitForDiagnostics(opts.AbsPath, opts.DiagnosticTimeout)
	}
	if d, ok := p.LastDiagnostics(opts.AbsPath); ok {
		result.FinalDiagnostics = d
	}
	for f := range touched {
		result.FilesTouched = append(result.FilesTouched, f)
	}
	sort.Strings(result.FilesTouched)
	return result, nil
}

// ApplyCodeAction is the single-action analogue of FixAllInFile.
// Resolves the action if its Edit is nil, then applies the resulting
// WorkspaceEdit. Returns the list of touched files.
func (p *Provider) ApplyCodeAction(action CodeActionOrCommand) ([]string, error) {
	edit := action.Edit
	if edit == nil && (action.Data != nil || action.Title != "") {
		// Try to resolve.
		resolved, err := p.ResolveCodeAction(action)
		if err == nil && resolved.Edit != nil {
			edit = resolved.Edit
		}
	}
	if edit == nil && action.IsCommand() {
		_, err := p.ExecuteCommand(Command{Command: action.Command, Arguments: action.Arguments, Title: action.Title})
		return nil, err
	}
	if edit == nil {
		return nil, errors.New("code action has no applicable edit or command")
	}
	files, err := WriteWorkspaceEdit(*edit)
	if err != nil {
		return nil, err
	}
	for _, f := range files {
		_ = p.changeDocument(f, mustReadFile(f))
	}
	return files, nil
}

// WriteWorkspaceEdit applies a WorkspaceEdit to disk. Both the legacy
// `changes` field and the modern `documentChanges` field are
// supported. Returns the absolute paths of files whose contents
// changed.
//
// Edits within a single file are applied in reverse position order so
// earlier edits don't shift the offsets of later ones.
func WriteWorkspaceEdit(edit WorkspaceEdit) ([]string, error) {
	type fileEdit struct {
		path  string
		edits []TextEdit
	}
	var grouped []fileEdit
	if len(edit.DocumentChanges) > 0 {
		for _, dc := range edit.DocumentChanges {
			path := uriToAbsPath(dc.TextDocument.URI)
			if path == "" {
				continue
			}
			grouped = append(grouped, fileEdit{path: path, edits: dc.Edits})
		}
	} else {
		for uri, edits := range edit.Changes {
			path := uriToAbsPath(uri)
			if path == "" {
				continue
			}
			grouped = append(grouped, fileEdit{path: path, edits: edits})
		}
	}

	touched := make([]string, 0, len(grouped))
	for _, fe := range grouped {
		content, err := os.ReadFile(fe.path)
		if err != nil {
			return touched, fmt.Errorf("read %s: %w", fe.path, err)
		}
		newContent, err := applyEditsToContent(content, fe.edits)
		if err != nil {
			return touched, fmt.Errorf("apply edits to %s: %w", fe.path, err)
		}
		// Atomic write: temp + rename.
		tmp, err := os.CreateTemp(filepath.Dir(fe.path), ".gortex-edit-*")
		if err != nil {
			return touched, err
		}
		if _, err := tmp.Write(newContent); err != nil {
			_ = tmp.Close()
			_ = os.Remove(tmp.Name())
			return touched, err
		}
		if err := tmp.Close(); err != nil {
			return touched, err
		}
		if err := os.Rename(tmp.Name(), fe.path); err != nil {
			_ = os.Remove(tmp.Name())
			return touched, err
		}
		touched = append(touched, fe.path)
	}
	return touched, nil
}

// applyEditsToContent walks the edits in reverse-position order and
// returns the resulting bytes. Position arithmetic is line/char in
// UTF-16 units per LSP — for the ASCII-only common case this matches
// byte offsets; for unicode-heavy code it underestimates UTF-16 units
// (a known LSP/Go interop wart, present across major LSP clients).
func applyEditsToContent(content []byte, edits []TextEdit) ([]byte, error) {
	if len(edits) == 0 {
		return content, nil
	}
	// Sort edits by start position descending so applying one
	// doesn't invalidate the offsets of the next.
	sorted := make([]TextEdit, len(edits))
	copy(sorted, edits)
	sort.Slice(sorted, func(i, j int) bool {
		if sorted[i].Range.Start.Line != sorted[j].Range.Start.Line {
			return sorted[i].Range.Start.Line > sorted[j].Range.Start.Line
		}
		return sorted[i].Range.Start.Character > sorted[j].Range.Start.Character
	})

	out := make([]byte, len(content))
	copy(out, content)
	for _, e := range sorted {
		startOff, err := lspPositionToOffset(out, e.Range.Start)
		if err != nil {
			return nil, err
		}
		endOff, err := lspPositionToOffset(out, e.Range.End)
		if err != nil {
			return nil, err
		}
		if startOff > endOff || startOff < 0 || endOff > len(out) {
			return nil, fmt.Errorf("invalid edit range: start=%d end=%d len=%d", startOff, endOff, len(out))
		}
		newBytes := []byte(e.NewText)
		merged := make([]byte, 0, len(out)-(endOff-startOff)+len(newBytes))
		merged = append(merged, out[:startOff]...)
		merged = append(merged, newBytes...)
		merged = append(merged, out[endOff:]...)
		out = merged
	}
	return out, nil
}

// lspPositionToOffset converts an LSP Position (line, char) into a
// byte offset within content. Treats char as UTF-16 code units only
// when the line contains non-ASCII bytes; for ASCII-only lines it's
// equivalent to a byte index — the common case.
func lspPositionToOffset(content []byte, pos Position) (int, error) {
	off := 0
	line := 0
	for off < len(content) && line < pos.Line {
		if content[off] == '\n' {
			line++
		}
		off++
	}
	if line < pos.Line {
		// Position past EOF — clamp to len(content).
		return len(content), nil
	}
	// Walk Character UTF-16 code units within the line.
	col := 0
	for off < len(content) && col < pos.Character {
		b := content[off]
		if b == '\n' {
			break
		}
		// ASCII fast path.
		if b < 0x80 {
			off++
			col++
			continue
		}
		// UTF-8 multi-byte sequence.
		size := utf8RuneSize(b)
		if size <= 0 {
			size = 1
		}
		if off+size > len(content) {
			break
		}
		// One UTF-16 code unit for BMP, two for supplementary.
		r := decodeUTF8(content[off : off+size])
		if r >= 0x10000 {
			col += 2
		} else {
			col++
		}
		off += size
	}
	return off, nil
}

func utf8RuneSize(b byte) int {
	switch {
	case b&0x80 == 0:
		return 1
	case b&0xE0 == 0xC0:
		return 2
	case b&0xF0 == 0xE0:
		return 3
	case b&0xF8 == 0xF0:
		return 4
	}
	return 1
}

// decodeUTF8 returns the rune for the given byte slice (assumed to
// start a valid UTF-8 sequence).
func decodeUTF8(b []byte) int32 {
	switch len(b) {
	case 1:
		return int32(b[0])
	case 2:
		return int32(b[0]&0x1F)<<6 | int32(b[1]&0x3F)
	case 3:
		return int32(b[0]&0x0F)<<12 | int32(b[1]&0x3F)<<6 | int32(b[2]&0x3F)
	case 4:
		return int32(b[0]&0x07)<<18 | int32(b[1]&0x3F)<<12 | int32(b[2]&0x3F)<<6 | int32(b[3]&0x3F)
	}
	return 0
}

// sortActions stabilises code-action ordering across servers: prefer
// IsPreferred, then by kind specificity (quickfix first, then
// organizeImports, then refactor, then source), then by title.
func sortActions(actions []CodeActionOrCommand) {
	rank := func(k string) int {
		switch k {
		case CodeActionKindQuickFix:
			return 0
		case CodeActionKindSourceOrganizeImports:
			return 1
		case CodeActionKindSourceFixAll:
			return 2
		case CodeActionKindRefactor, CodeActionKindRefactorExtract,
			CodeActionKindRefactorInline, CodeActionKindRefactorRewrite:
			return 3
		case CodeActionKindSource:
			return 4
		}
		return 5
	}
	sort.SliceStable(actions, func(i, j int) bool {
		a, b := actions[i], actions[j]
		if a.IsPreferred != b.IsPreferred {
			return a.IsPreferred
		}
		ra, rb := rank(a.Kind), rank(b.Kind)
		if ra != rb {
			return ra < rb
		}
		return a.Title < b.Title
	})
}

// r0 picks a sensible workspace root for an opened document — for
// most LSP servers the workspace root in the initialize call is the
// truth, but FixAllInFile may be called against a path under that
// root. We don't have access to the original workspaceRoot here, so
// fall back to the directory containing the file.
func r0(spec *ServerSpec, abs string) string {
	_ = spec
	return filepath.Dir(abs)
}

// relForOpen returns abs minus the leading repoRoot — so openDocument
// can join them back together. Used when the caller has only the
// absolute path. Returns the original absolute path when it isn't
// under repoRoot (openDocument tolerates absolute paths via
// filepath.Join's semantics: when relPath is absolute it overrides
// the prefix).
func relForOpen(repoRoot, abs string) string {
	if rel, err := filepath.Rel(repoRoot, abs); err == nil && !strings.HasPrefix(rel, "..") {
		return rel
	}
	return abs
}

// mustReadFile reads the entire file as a string. Returns the empty
// string on error so the caller's didChange notify still has a
// well-formed payload.
func mustReadFile(abs string) string {
	b, err := os.ReadFile(abs)
	if err != nil {
		return ""
	}
	return string(b)
}
