package linthost

import (
  "bufio"
  "encoding/json"
  "fmt"
  "io"
  "os"
  "strings"
  "sync"

  shimast "github.com/microsoft/typescript-go/shim/ast"
)

// residentPrograms is the warm-Program cache the resident lsp-serve loop
// installs for the life of the daemon. It is nil in every one-shot verb
// invocation, so acquireProgram falls straight through to loadProgram and the
// spawn-per-verb path behaves byte-identically to before. Only RunLSPServe sets
// it, and the serve loop is single-threaded, but the mutex keeps the invariant
// explicit and cheap.
var residentPrograms *residentProgramCache

// residentProgramCache holds one loaded Program per (cwd, tsconfig, checker)
// key. A resident daemon pays the cold NewProgram parse+bind and the standalone
// checker build once, then reuses them across LSP verbs until a document change
// invalidates the cache. The expensive work is the parse+bind+checker; the
// per-verb rule walk is re-run each time (see acquire) because different verbs
// resolve different rule sets.
type residentProgramCache struct {
  mu      sync.Mutex
  entries map[string]*program
}

func newResidentProgramCache() *residentProgramCache {
  return &residentProgramCache{entries: map[string]*program{}}
}

// residentProgramKey identifies one cached Program. The checker suffix keeps a
// checker-free Program from being handed to a verb whose rules read one.
func residentProgramKey(opts *lspCommandOptions, needsChecker bool) string {
  key := opts.cwd + "\x00" + opts.tsconfig + "\x00"
  if needsChecker {
    key += "checker"
  }
  return key
}

// acquire returns a warm Program for the key, building and caching it on a miss.
// On a hit it resets the memoized project cycle so the caller's engine — which
// may differ from the engine that first warmed this Program (a lint verb versus
// a format verb) — re-evaluates its own project and file rules over the reused
// ASTs and checker. The returned close func is a no-op: a cached Program
// outlives the verb and is released only by invalidate or daemon exit.
//
// One project keeps one Program. A checker-bearing Program answers a verb that
// needs no checker, and a verb that does need one releases the checker-free
// entry it supersedes, so the daemon's memory does not depend on which verb the
// editor happened to ask for first.
func (c *residentProgramCache) acquire(
  opts *lspCommandOptions,
  needsChecker bool,
) (*program, []*shimast.Diagnostic, func(), error) {
  key := residentProgramKey(opts, needsChecker)
  c.mu.Lock()
  defer c.mu.Unlock()
  if prog, ok := c.entries[key]; ok {
    // Drop the prior verb's project-cycle memo so this verb's engine re-runs.
    prog.projectCycle = nil
    return prog, nil, noopClose, nil
  }
  if !needsChecker {
    // A Program that has a checker satisfies a verb that needs none. Without
    // this the daemon would hold two Programs for one project as soon as a
    // checker-free verb (lsp-hints, whose publishers declined a checker) joined
    // a checker-bearing one (lsp-diagnostics with a type-aware rule enabled) —
    // doubling the memory the resident cache exists to bound.
    if prog, ok := c.entries[residentProgramKey(opts, true)]; ok {
      prog.projectCycle = nil
      return prog, nil, noopClose, nil
    }
  }
  prog, diags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
    forceNoEmit:        true,
    needsRuleChecker:   needsChecker,
    projectIdentity:    opts.projectIdentity,
    semanticConfigPath: opts.semanticConfigPath,
  })
  if err != nil {
    return nil, nil, noopClose, err
  }
  if len(diags) > 0 || prog == nil {
    // A project that does not parse right now (a save mid-edit) is not cached —
    // the next verb rebuilds. tsgo owns these diagnostics upstream anyway.
    return prog, diags, noopClose, nil
  }
  if needsChecker {
    // The checker-free entry for this project, if one exists, is now redundant:
    // every verb that could use it can use this one. Releasing it here is what
    // keeps the invariant at one Program per project in both arrival orders.
    // Requests are served serially by the RunLSPServe loop, so no verb still
    // holds the Program being closed.
    plain := residentProgramKey(opts, false)
    if previous, ok := c.entries[plain]; ok {
      previous.close()
      delete(c.entries, plain)
    }
  }
  c.entries[key] = prog
  return prog, nil, noopClose, nil
}

// invalidate releases every cached Program. Called when the editor signals that
// a document in the project changed on disk (didSave) or opened, so the next
// verb rebuilds against current source.
func (c *residentProgramCache) invalidate() {
  c.mu.Lock()
  defer c.mu.Unlock()
  for key, prog := range c.entries {
    prog.close()
    delete(c.entries, key)
  }
}

func noopClose() {}

// applyChanges incrementally updates every cached Program for the changed files,
// or drops an entry for a full reload when a changed path is not one of its
// source files — a config edit, or a new or removed file, which tsgo's
// per-file UpdateProgram cannot express and a fresh load handles correctly.
func (c *residentProgramCache) applyChanges(
  paths []string,
  external map[string]struct{},
) {
  if len(paths) == 0 {
    return
  }
  c.mu.Lock()
  defer c.mu.Unlock()
  for key, prog := range c.entries {
    fullReload := false
    for _, path := range paths {
      if prog.sourceFileByPath(path) == nil {
        if _, ok := external[canonicalProjectPath("", realProjectPath(path))]; ok {
          continue
        }
        fullReload = true
        break
      }
    }
    if fullReload {
      prog.close()
      delete(c.entries, key)
      continue
    }
    for _, path := range paths {
      if prog.sourceFileByPath(path) != nil {
        prog.applyChange(path)
      }
    }
  }
}

// acquireProgram returns the Program an LSP verb should lint over, plus the
// close func the verb must defer. Outside a resident daemon (residentPrograms
// nil) it builds a fresh Program exactly as before and returns its real close,
// so the spawn-per-verb path is unchanged. Inside a daemon it returns the warm
// cached Program and a no-op close.
func acquireProgram(
  opts *lspCommandOptions,
  needsChecker bool,
) (*program, []*shimast.Diagnostic, func(), error) {
  if residentPrograms != nil {
    return residentPrograms.acquire(opts, needsChecker)
  }
  prog, diags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
    forceNoEmit:        true,
    needsRuleChecker:   needsChecker,
    projectIdentity:    opts.projectIdentity,
    semanticConfigPath: opts.semanticConfigPath,
  })
  if prog == nil {
    return prog, diags, noopClose, err
  }
  return prog, diags, prog.close, err
}

// serveLSPRequest is one newline-delimited request the resident daemon reads.
// The base project options (cwd, tsconfig, plugins-json, project-context) are
// fixed for the daemon's life and arrive as RunLSPServe's argv; only the
// per-verb fields travel per request.
type serveLSPRequest struct {
  Verb        string `json:"verb"`
  URI         string `json:"uri,omitempty"`
  RangeJSON   string `json:"rangeJson,omitempty"`
  ContextJSON string `json:"contextJson,omitempty"`
  // Invalidate drops the warm Program before serving. The client sends it for a
  // change it cannot localize, so the request that carries it rebuilds.
  Invalidate bool `json:"invalidate,omitempty"`
  // Changed carries document URIs that changed on disk (a didSave), so the warm
  // Program is updated incrementally — re-parsing only those files — before
  // serving, instead of rebuilt from scratch. The sidecar owns the URI-to-path
  // conversion so the spelling matches its own source-file names. A file the
  // Program does not already hold as a source (a config edit, a new or removed
  // file) drops the entry for a full reload instead.
  Changed []string `json:"changed,omitempty"`
  // External marks changed URIs that belong to declared ProjectRule inputs.
  // Unknown external files invalidate fresh rule state but not the TypeScript
  // Program; a path that is also a source file still updates incrementally.
  External []string `json:"external,omitempty"`
}

// serveLSPResponse is the reply to one request: the verb's JSON result verbatim
// (the same bytes the one-shot verb would print) and the exit code that verb
// would have returned. A nonzero code with a null result means the verb failed;
// the client falls back rather than treating it as an empty answer.
type serveLSPResponse struct {
  Result json.RawMessage `json:"result"`
  Code   int             `json:"code"`
}

// RunLSPServe is the resident @ttsc/lint LSP daemon. It installs a warm-Program
// cache and answers newline-delimited verb requests read from in by writing one
// JSON reply per line to out, until in reaches EOF.
//
// Only the read verbs run resident here. lsp-diagnostics and lsp-hints (one per
// save) and lsp-code-actions (one per cursor) are the hot path and reuse the
// warm Program, as do lsp-project-diagnostics and graph-nodes; project-inputs
// builds no Program and reuses only the rule memo; lsp-command-ids and
// lsp-code-action-kinds answer their static lists; an invalidate control drops
// the Program. lsp-execute-command is deliberately left
// to the spawn-per-verb path — it is user-initiated and its temp-workspace fix
// cascade does not fit the resident cache.
//
// in and out are explicit so the loop is testable; dispatch wires them to
// os.Stdin and os.Stdout.
//
// Requests run serially under fixed project options. Only one resident session
// may install these process-global caches at a time. The daemon drops warm
// Program references when invalidated, superseded or stopped. Callers supply nonnil
// streams. Startup, input and reply-write failures return 2; verb failures are
// returned in the reply.
//
// @evidence contracts/common.md#principled-implementation Explicit read verbs preserve one-shot result semantics while fixed project options and compiler-compatible source transitions determine warm Program reuse; write commands keep their separate temporary-workspace ownership.
// @evidence contracts/common.md#clear-and-simple-design One serial protocol loop owns caches and dispatch; per-verb computation retains its existing responsibility and project-cycle state is reset at acquisition.
// @evidence contracts/common.md#prohibited-implementation-shortcuts Compiler incremental updates and explicit reloads handle supported source transitions; a checker-bearing Program legitimately serves checker-free requests without disguising missing checker capability.
// @evidence contracts/common.md#meaningful-documentation Native prose distinguishes resident read verbs, one-shot writes, streams, resource release and failure channels; paragraphs and tags follow documentation guidance.
// @evidence contracts/portability.md#os-neutral-implementation Changed file URIs pass through native URI conversion and physical project identity helpers, so source matching uses compiler paths instead of assumed drive spelling or case folding.
// @evidence contracts/performance.md#efficient-algorithms Each line is read/parsed and changed/external URI bytes are normalized before the chosen verb. For each retained Program, changed-source membership scans its source-file list, repeats during application and adds physical-name/path work; compiler updates and the chosen acquisition/rule/projection/diagnostic workload remain input-dependent. Result marshaling and response encoding add payload bytes and buffers. No globally linear transition or fixed complete-session cost is claimed.
// @evidence contracts/performance.md#reuse-equivalent-work Fixed session options define Program keys, and caller-reported compatible changed sources use compiler updates; unknown non-external topology changes drop entries and checker-bearing entries satisfy checker-free requests. Acquisition clears the previous project-cycle memo before each reused evaluation. Rule-config memo reuse separately validates recorded dependency fingerprints. Omitted changes or incorrect external classifications are not independently discovered by this protocol, and effectful verb results have no address-only cache.
// @evidence contracts/performance.md#bound-retention-and-release-resources Within the supported single fixed-project session, one current Program entry is retained; upgrades/invalidation/exit drop standalone checkers and references. The latest project-cycle memo can remain until the next acquisition/update/invalidation. The rule memo holds one resolver snapshot, while executable config caches retain separate process/disk history without eviction here. Deferred session cleanup clears the global caches, and streams remain caller-owned. Input/response, Program and dependency bytes have no fixed cap or caller deadline; blocked streams/evaluators can keep the synchronous session alive.
func RunLSPServe(in io.Reader, out io.Writer, args []string) int {
  base, ok := parseLSPCommandOptions("lsp-serve", args)
  if !ok {
    return 2
  }
  residentPrograms = newResidentProgramCache()
  residentRules = &residentRuleCache{}
  defer func() {
    residentPrograms.invalidate()
    residentPrograms = nil
    residentRules = nil
  }()
  encoder := json.NewEncoder(out)
  // ReadString imposes no line-length limit: a request is small, but keeping the
  // same reader shape as utility/serve.go avoids an arbitrary ceiling if a
  // future field carries buffer text.
  reader := bufio.NewReader(in)
  for {
    raw, err := reader.ReadString('\n')
    if line := strings.TrimSpace(raw); line != "" {
      if err := handleServeLSPLine(line, base, encoder); err != nil {
        fmt.Fprintf(os.Stderr, "@ttsc/lint lsp-serve: write error: %v\n", err)
        return 2
      }
    }
    if err != nil {
      if err != io.EOF {
        fmt.Fprintf(os.Stderr, "@ttsc/lint lsp-serve: read error: %v\n", err)
        return 2
      }
      return 0
    }
  }
}

// handleServeLSPLine answers one request line. A panic inside a rule cycle is
// contained so one bad request drops to a nonzero code rather than taking down
// the daemon and every document with it — the resident analog of the one-shot
// process's isolation.
func handleServeLSPLine(line string, base *lspCommandOptions, encoder *json.Encoder) (writeErr error) {
  defer func() {
    if recovered := recover(); recovered != nil {
      fmt.Fprintf(os.Stderr, "@ttsc/lint lsp-serve: request panicked: %v\n", recovered)
      writeErr = encoder.Encode(serveLSPResponse{Code: 2})
    }
  }()
  var req serveLSPRequest
  if err := json.Unmarshal([]byte(line), &req); err != nil {
    return encoder.Encode(serveLSPResponse{Code: 2})
  }
  if req.Invalidate {
    // The Program only. The rule memo validates itself against the files it was
    // loaded from, so it needs no help from a client — and dropping it here
    // would re-evaluate the project's configuration on exactly the requests
    // that carry this control, which is every request from a consumer that
    // cannot localize a source change. That is the cost the memo exists to
    // remove.
    residentPrograms.invalidate()
  }
  if len(req.Changed) > 0 {
    paths := make([]string, 0, len(req.Changed))
    external := make(map[string]struct{}, len(req.External))
    for _, uri := range req.External {
      if path, err := filePathFromURI(uri); err == nil {
        external[canonicalProjectPath("", realProjectPath(path))] = struct{}{}
      }
    }
    for _, uri := range req.Changed {
      if path, err := filePathFromURI(uri); err == nil {
        paths = append(paths, path)
      }
    }
    residentPrograms.applyChanges(paths, external)
  }
  opts := *base
  opts.uri = req.URI
  opts.rangeJSON = req.RangeJSON
  opts.contextJSON = req.ContextJSON
  switch req.Verb {
  case "lsp-diagnostics":
    result, code := computeLSPDiagnostics(&opts)
    return encodeServeResult(encoder, result, code)
  case "lsp-project-diagnostics":
    result, code := computeLSPProjectDiagnostics(&opts)
    return encodeServeResult(encoder, result, code)
  case "lsp-code-actions":
    result, code := computeLSPCodeActions(&opts)
    return encodeServeResult(encoder, result, code)
  case "lsp-hints":
    result, code := computeLSPHints(&opts)
    return encodeServeResult(encoder, result, code)
  case "graph-nodes":
    // Neither of these describes a document, so neither carries a uri. They
    // describe the project, and they join the daemon for the reason the other
    // read verbs did: a consumer that asks them again whenever a file it
    // watches moves would otherwise pay a process, a plugin load, and a Program
    // for every edit.
    result, code := computeGraphNodes(&opts)
    return encodeServeResult(encoder, result, code)
  case "project-inputs":
    result, code := computeProjectInputs(&opts)
    return encodeServeResult(encoder, result, code)
  case "lsp-command-ids":
    return encodeServeResult(encoder, lspCommandIDs(), 0)
  case "lsp-code-action-kinds":
    return encodeServeResult(encoder, lspCodeActionKinds(), 0)
  case "":
    // A bare invalidate carries no verb; acknowledge it.
    return encoder.Encode(serveLSPResponse{Code: 0})
  default:
    fmt.Fprintf(os.Stderr, "@ttsc/lint lsp-serve: unknown verb %q\n", req.Verb)
    return encoder.Encode(serveLSPResponse{Code: 2})
  }
}

// encodeServeResult marshals a verb result and writes it with its code. A
// marshal failure degrades to a nonzero code rather than a malformed line.
func encodeServeResult(encoder *json.Encoder, result any, code int) error {
  if code != 0 {
    return encoder.Encode(serveLSPResponse{Code: code})
  }
  raw, err := json.Marshal(result)
  if err != nil {
    return encoder.Encode(serveLSPResponse{Code: 2})
  }
  return encoder.Encode(serveLSPResponse{Result: raw, Code: 0})
}
