package linthost

import (
  "encoding/json"
  "errors"
  "fmt"
  "os"
  "path/filepath"
  "sort"
  "strings"

  shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
  shimtspath "github.com/microsoft/typescript-go/shim/tspath"
)

// nativeInputPredicate retains the actual config loader's fingerprint semantics.
// The wire version is independent of the executable config cache namespace.
type nativeInputPredicate struct {
  Version        int     `json:"version"`
  Kind           string  `json:"kind"`
  Digest         string  `json:"digest"`
  IdentityStable bool    `json:"identityStable"`
  Realpath       *string `json:"realpath"`
  Scope          string  `json:"scope"`
}

// lintCheckGraph is a private wire schema matching the compiler's check result.
// The standalone public lint module cannot import the unpublished ttsc driver.
// Its source and predicate proofs come from the same Program's host filesystem.
type lintCheckGraph struct {
  Edges                     map[string][]string                  `json:"edges"`
  Globals                   []string                             `json:"globals"`
  Configs                   []string                             `json:"configs"`
  ResolutionInputs          []string                             `json:"resolutionInputs,omitempty"`
  InputObservations         map[string]transformInputObservation `json:"inputObservations,omitempty"`
  InputHashes               map[string]*string                   `json:"inputHashes,omitempty"`
  InputRealpaths            map[string]*string                   `json:"inputRealpaths,omitempty"`
  InputProofFailures        map[string]string                    `json:"inputProofFailures,omitempty"`
  UseCaseSensitiveFileNames bool                                 `json:"useCaseSensitiveFileNames"`
}

// lintInputKey uses relative wire spelling only inside cwd; cross-volume and
// outside-root paths retain absolute native addresses with slash separators.
func lintInputKey(cwd, name string) string {
  relative, err := filepath.Rel(cwd, name)
  if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
    return filepath.ToSlash(name)
  }
  return filepath.ToSlash(relative)
}

// checkGraph projects this Program's resident sources and observed predicates.
// Sorting makes wire lists deterministic. Config fingerprints keep their own
// versioned kind/scope encoding; disagreeing same-kind witnesses remain failed.
// No source file is reread to fill a missing construction-time observation.
func (p *program) checkGraph() *lintCheckGraph {
  if p == nil || p.tsProgram == nil || p.inputObserver == nil {
    return nil
  }
  graph := &lintCheckGraph{Edges: map[string][]string{}, Globals: []string{}, Configs: []string{}, ResolutionInputs: []string{}, InputObservations: map[string]transformInputObservation{}, InputHashes: map[string]*string{}, InputRealpaths: map[string]*string{}, InputProofFailures: map[string]string{}, UseCaseSensitiveFileNames: p.tsProgram.UseCaseSensitiveFileNames()}
  realized := map[string]bool{}
  for _, file := range p.tsProgram.SourceFiles() {
    if file == nil || strings.HasPrefix(string(file.FileName()), "bundled:///") {
      continue
    }
    key := lintInputKey(p.cwd, string(file.FileName()))
    realized[string(file.FileName())] = true
    targets := []string{}
    // The shim returns canonical tspath.PathKey values, not filenames. Preserve
    // that exact key and recover this Program's resident source spelling;
    // unresolved candidates are not realized edges.
    for _, referenced := range shimcompiler.GetReferencedFilePaths(p.tsProgram, file) {
      source := p.tsProgram.GetSourceFileByPath(shimtspath.PathKey(referenced))
      if source == nil || source == file || strings.HasPrefix(string(source.FileName()), "bundled:///") {
        continue
      }
      realized[string(source.FileName())] = true
      targets = append(targets, lintInputKey(p.cwd, string(source.FileName())))
    }
    sort.Strings(targets)
    graph.Edges[key] = targets
    if shimcompiler.FileAffectsGlobalScope(file) {
      graph.Globals = append(graph.Globals, key)
    }
  }
  sort.Strings(graph.Globals)
  if p.parsed != nil && p.parsed.ConfigFile != nil {
    if file := p.parsed.ConfigFile.SourceFile; file != nil {
      realized[string(file.FileName())] = true
      graph.Configs = append(graph.Configs, lintInputKey(p.cwd, string(file.FileName())))
    }
    for _, file := range p.parsed.ExtendedSourceFiles() {
      realized[string(file)] = true
      graph.Configs = append(graph.Configs, lintInputKey(p.cwd, string(file)))
    }
  }
  // Conservatively universalize actual compiler predicates. This needs no
  // second resolver or filesystem read and cannot drop an importing owner.
  paths := map[string]bool{}
  for _, name := range p.inputObserver.observedPaths() {
    if !strings.HasPrefix(name, "bundled:///") {
      paths[name] = true
      graph.ResolutionInputs = append(graph.ResolutionInputs, lintInputKey(p.cwd, name))
    }
  }
  for name := range realized {
    paths[name] = true
  }
  sort.Strings(graph.ResolutionInputs)
  for name := range paths {
    key := lintInputKey(p.cwd, name)
    observation, failure := p.inputObserver.predicateProof(name)
    if failure != "" {
      graph.InputProofFailures[key] = string(failure)
      continue
    }
    graph.InputObservations[key] = observation
    hash, physical, failure := p.inputObserver.proof(name)
    if failure == "" {
      graph.InputHashes[key] = hash
      graph.InputRealpaths[key] = physical
    } else if realized[name] {
      graph.InputProofFailures[key] = string(failure)
    }
  }
  for _, input := range p.configInputs {
    key := lintInputKey(p.cwd, input.Path)
    observation := graph.InputObservations[key]
    observation.NativePredicates = append(observation.NativePredicates, nativeInputPredicate{Version: 1, Kind: input.Kind, Digest: input.Digest, IdentityStable: input.IdentityStable, Realpath: cloneConfigDependencyRealpath(input.Realpath), Scope: input.Scope})
    graph.InputObservations[key] = observation
    graph.ResolutionInputs = append(graph.ResolutionInputs, key)
    if !input.IdentityStable {
      graph.InputProofFailures[key] = "config-identity-unavailable"
    }
  }
  if p.inputReader != nil {
    p.inputReader.mu.Lock()
    for name, predicate := range p.inputReader.nativeInputs {
      key := lintInputKey(p.cwd, name)
      observation := graph.InputObservations[key]
      observation.NativePredicates = append(observation.NativePredicates, predicate)
      graph.InputObservations[key] = observation
      graph.ResolutionInputs = append(graph.ResolutionInputs, key)
    }
    for name, predicate := range p.inputReader.directoryInputs {
      key := lintInputKey(p.cwd, name)
      observation := graph.InputObservations[key]
      observation.NativePredicates = append(observation.NativePredicates, predicate)
      graph.InputObservations[key] = observation
      graph.ResolutionInputs = append(graph.ResolutionInputs, key)
    }
    p.inputReader.mu.Unlock()
  }
  sort.Strings(graph.ResolutionInputs)
  for key, observation := range graph.InputObservations {
    unique := map[string]nativeInputPredicate{}
    byKind := map[string]nativeInputPredicate{}
    for _, predicate := range observation.NativePredicates {
      if prior, seen := byKind[predicate.Kind]; seen && (prior.Digest != predicate.Digest || prior.IdentityStable != predicate.IdentityStable || !equalObservedString(prior.Realpath, predicate.Realpath)) {
        graph.InputProofFailures[key] = "config-predicate-conflict"
      }
      byKind[predicate.Kind] = predicate
      unique[predicate.Kind+":"+predicate.Scope] = predicate
    }
    observation.NativePredicates = nil
    for _, predicate := range unique {
      observation.NativePredicates = append(observation.NativePredicates, predicate)
    }
    sort.Slice(observation.NativePredicates, func(i, j int) bool {
      return observation.NativePredicates[i].Kind+":"+observation.NativePredicates[i].Scope < observation.NativePredicates[j].Kind+":"+observation.NativePredicates[j].Scope
    })
    graph.InputObservations[key] = observation
  }
  return graph
}

// writeCheckObservations publishes before the owning Program is released.
// Absent Programs and withdrawn readers publish false, never invented true.
// The invocation owns its temporary file through close/rename and reports
// non-missing cleanup errors alongside the original publication error.
func (p *program) writeCheckObservations(name string) (resultErr error) {
  if !filepath.IsAbs(name) {
    return fmt.Errorf("@ttsc/lint: check observations path must be absolute: %q", name)
  }
  inputs := []string{}
  hashes, physicals := map[string]*string{}, map[string]*string{}
  complete := p != nil && p.tsProgram != nil && p.inputReader != nil
  if p != nil && p.inputReader != nil {
    reader := p.inputReader
    reader.mu.Lock()
    complete = complete && !reader.incomplete
    for name, hash := range reader.inputs {
      inputs = append(inputs, name)
      hashes[name] = hash
      physicals[name] = reader.realpaths[name]
    }
    reader.mu.Unlock()
  }
  sort.Strings(inputs)
  var graph *lintCheckGraph
  if p != nil {
    graph = p.checkGraph()
  }
  var unavailable *bool
  if !complete {
    value := false
    unavailable = &value
  }
  data, err := json.Marshal(struct {
    Graph                *lintCheckGraph    `json:"graph,omitempty"`
    HostInputs           []string           `json:"hostInputs"`
    HostInputHashes      map[string]*string `json:"hostInputHashes"`
    HostInputRealpaths   map[string]*string `json:"hostInputRealpaths"`
    ObservationsComplete *bool              `json:"observationsComplete,omitempty"`
  }{graph, inputs, hashes, physicals, unavailable})
  if err != nil {
    return err
  }
  temp, err := os.CreateTemp(filepath.Dir(name), ".lint-check-observations-*")
  if err != nil {
    return err
  }
  temporary := temp.Name()
  defer func() {
    if err := os.Remove(temporary); err != nil && !os.IsNotExist(err) {
      resultErr = errors.Join(resultErr, err)
    }
  }()
  if _, err = temp.Write(data); err != nil {
    return errors.Join(err, temp.Close())
  }
  if err = temp.Close(); err != nil {
    return err
  }
  return os.Rename(temporary, name)
}
