// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package cmd

import (
	"context"
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"sync"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"golang.org/x/tools/gopls/internal/cache"
	"golang.org/x/tools/gopls/internal/filewatcher"
	internalmcp "golang.org/x/tools/gopls/internal/mcp"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/gopls/internal/settings"
)

type headlessMCP struct {
	app *Application

	Address      string `flag:"listen" help:"the address on which to run the mcp server"`
	Logfile      string `flag:"logfile" help:"filename to log to; if unset, logs to stderr"`
	RPCTrace     bool   `flag:"rpc.trace" help:"print MCP rpc traces; cannot be used with -listen"`
	Instructions bool   `flag:"instructions" help:"if set, print gopls' MCP instructions and exit"`
}

func (m *headlessMCP) Name() string      { return "mcp" }
func (m *headlessMCP) Parent() string    { return m.app.Name() }
func (m *headlessMCP) Usage() string     { return "[mcp-flags]" }
func (m *headlessMCP) ShortHelp() string { return "start the gopls MCP server in headless mode" }

func (m *headlessMCP) DetailedHelp(f *flag.FlagSet) {
	fmt.Fprint(f.Output(), `
Starts the gopls MCP server in headless mode, without needing an LSP client.
Starts the server over stdio or sse with http, depending on whether the listen flag is provided.

Examples:
  $ gopls mcp -listen=localhost:3000
  $ gopls mcp  //start over stdio
`)
	printFlagDefaults(f)
}

func (m *headlessMCP) Run(ctx context.Context, args ...string) error {
	if m.Instructions {
		fmt.Println(internalmcp.Instructions)
		return nil
	}
	if m.Address != "" && m.RPCTrace {
		// There's currently no way to plumb logging instrumentation into the SSE
		// transport that is created on connections to the HTTP handler, so we must
		// disallow the -rpc.trace flag when using -listen.
		return fmt.Errorf("-listen is incompatible with -rpc.trace")
	}
	if m.Logfile != "" {
		f, err := os.Create(m.Logfile)
		if err != nil {
			return fmt.Errorf("opening logfile: %v", err)
		}
		log.SetOutput(f)
		defer f.Close()
	}

	// Start a new in-process gopls session and create a fake client
	// to connect to it.
	cli, sess, err := m.app.connect(ctx)
	if err != nil {
		return err
	}
	defer cli.terminate(ctx)

	var (
		queueMu  sync.Mutex
		queue    []protocol.FileEvent
		nonempty = make(chan struct{}) // receivable when len(queue) > 0
		stop     = make(chan struct{}) // closed when Run returns
	)
	defer close(stop)

	// This goroutine forwards file change events to the LSP server.
	go func() {
		for {
			select {
			case <-stop:
				return
			case <-nonempty:
				queueMu.Lock()
				q := queue
				queue = nil
				queueMu.Unlock()

				if len(q) > 0 {
					if err := cli.server.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{
						Changes: q,
					}); err != nil {
						log.Printf("failed to notify changed files: %v", err)
					}
				}

			}
		}
	}()

	// TODO(hxjiang): replace this with LSP initial param workspace root.
	dir, err := os.Getwd()
	if err != nil {
		return err
	}

	// Build directory skip function from directoryFilters so the file
	// watcher excludes the same directories that gopls analysis ignores
	// (e.g. node_modules). See https://github.com/xieyuschen/gopls-mcp/issues/10.
	var watcherOpts []filewatcher.Option
	if opts := settings.DefaultOptions(m.app.options); len(opts.DirectoryFilters) > 0 {
		pathIncluded := cache.PathIncludeFunc(opts.DirectoryFilters)
		root := filepath.Clean(dir)
		watcherOpts = append(watcherOpts, filewatcher.WithSkipDir(func(dirPath string) bool {
			rel, err := filepath.Rel(root, dirPath)
			if err != nil {
				return false
			}
			return !pathIncluded(filepath.ToSlash(rel))
		}))
	}

	errHandler := func(err error) {
		log.Printf("watch error: %v", err)
	}

	// TODO(hxjiang): enable file watcher based on the gopls setting.
	w, err := filewatcher.New("fsnotify", nil, func(events []protocol.FileEvent) {
		if len(events) == 0 {
			return
		}

		// Since there is no promise [protocol.Server.DidChangeWatchedFiles]
		// will return immediately, we should buffer the captured events and
		// sent them whenever available in a separate go routine.
		queueMu.Lock()
		queue = append(queue, events...)
		queueMu.Unlock()

		select {
		case nonempty <- struct{}{}:
		default:
		}
	}, errHandler, watcherOpts...)
	if err != nil {
		return err
	}
	defer w.Close()

	// TODO(hxjiang): in LSP's use case, the file watcher should watch for LSP
	// initial param workspace root.

	// TODO(hxjiang): refactor the queue pattern into a helper function to avoid
	// repetition.
	var (
		watchStop          = make(chan struct{}) // closed to broadcast "stop" event
		watchQueueNonempty = make(chan struct{}) // each send indicates "nonempty"
		watchQueueMu       sync.Mutex
		watchQueue         []string
	)
	// The watchStop event occurs when this function returns,
	// for any reason (cancellation or completion).
	defer close(watchStop)

	// TODO(hxjiang): memorize the roots and stop watching when the previously
	// watched roots are removed.
	// TODO(hxjiang): implement [filewatcher.Watcher]'s method StopWatchDir.

	// watchRoots is the callback triggered when the MCP client reports workspace
	// roots. We do not call w.WatchDir directly from this callback, but spawn a
	// goroutine for it because WatchDir performs OS-level filesystem operations
	// which can be slow. Blocking this callback would block the MCP server's
	// JSON-RPC message loop and stall the entireconnection.
	watchRoots := func(res *mcp.ListRootsResult, err error) {
		if err != nil {
			errHandler(err)
			return
		}
		watchQueueMu.Lock()
		for _, r := range res.Roots {
			uri, err := protocol.ParseDocumentURI(r.URI)
			if err != nil {
				// Discard invalid URIs.
				// Unlike LSP, MCP does not check URI validity during unmarshaling.
				// Fixes go.dev/issue/74652, crash of May 18 2026.
				log.Printf("mcp.ListRootsResult[*].Roots[*].URI contains invalid URI: %v", err)
				continue
			}
			watchQueue = append(watchQueue, uri.Path())
		}
		watchQueueMu.Unlock()

		select {
		case watchQueueNonempty <- struct{}{}:
		default:
		}
	}
	go func() {
		for {
			select {
			case <-watchStop:
				return
			case <-watchQueueNonempty:
				watchQueueMu.Lock()
				queue := watchQueue
				watchQueue = nil
				watchQueueMu.Unlock()

				for _, dir := range queue {
					if err := w.WatchDir(dir); err != nil {
						errHandler(err)
					}
				}
			}
		}
	}()

	if m.Address != "" {
		countHeadlessMCPSSE.Inc()
		return internalmcp.Serve(ctx, m.Address, &staticSessions{sess, cli.server}, false, watchRoots)
	} else {
		countHeadlessMCPStdIO.Inc()
		var rpcLog io.Writer
		if m.RPCTrace {
			rpcLog = log.Writer() // possibly redirected by -logfile above
		}
		log.Printf("Listening for MCP messages on stdin...")
		return internalmcp.StartStdIO(ctx, sess, cli.server, rpcLog, watchRoots)
	}
}

// staticSessions implements the [internalmcp.Sessions] interface for a single gopls
// session.
type staticSessions struct {
	session *cache.Session
	server  protocol.Server
}

func (s *staticSessions) SetSessionExitFunc(func(string)) {}

func (s *staticSessions) FirstSession() (*cache.Session, protocol.Server) {
	return s.session, s.server
}

func (s *staticSessions) Session(id string) (*cache.Session, protocol.Server) {
	if s.session.ID() == id {
		return s.session, s.server
	}
	return nil, nil
}
