/- A scrolling refactor algorithm: The algorithm ingests Lean compilation units
on one end and outputs compilation units on the other. -/
import Pantograph.Frontend.Basic
import Pantograph.Frontend.InfoTree
import Pantograph.Delate

open Lean

namespace Pantograph.Frontend

namespace Refactor

/-- A command in the input file, frozen in context -/
structure Command where
  dependencies : NameSet := .empty
  stx : Syntax
  trees : List Elab.InfoTree
  hasError : Bool := false
  constants : NameSet := .empty
  state : Elab.Command.State
  messages : List Message

inductive CommandCategory where
  -- Definition of data
  | data
  -- Definition fo theorems
  | declaration
  -- section, variable, universe
  | flow
  -- Things which can be discarded.
  | auxiliary
  -- No refactor units may cross an `.unknown` boundary.
  | unknown

protected def Command.category (command : Command) : CommandCategory :=
  match command.stx.getKind with
  | `Lean.Parser.Command.declaration =>
    match command.stx.getArg 2 |>.getKind with
    | `Lean.Parser.Command.structure
    | `Lean.Parser.Command.inductive
      => .data
    | `Lean.Parser.Command.theorem
    | `Lean.Parser.Command.definition
      => .data
    | _ => .unknown
  | `Lean.Parser.Command.section
  | `Lean.Parser.Command.namespace
  | `Lean.Parser.Command.variable
  | `Lean.Parser.Command.end
    => .flow
  | `Lean.Parser.Command.set_option
    => .auxiliary
  | _ => .unknown
protected def Command.comments (command : Command) : Syntax :=
  let modifiers := command.stx.getArg 0
  let comments := modifiers.getArg 0
  comments[0]

structure Config where
  coreOptions : Options := {}
  deriving Inhabited

structure Context where
  inContext : Parser.InputContext
  config : Config := {}

structure State where
  outContext : Parser.InputContext
  outState : Elab.Frontend.State

  -- Collected top-level units, scrolling
  commands : List Command := []

/-- Two monads rolled into one -/
abbrev RefactorM := ReaderT Context $ StateRefT State IO

def readConfig : RefactorM Config := do
  return (← read).config
def readCoreOptions : RefactorM Options := do
  return (← readConfig).coreOptions

def fail { α } (s : String) : IO α :=
  throw <| .userError s

def mergeFileMap (fm1 fm2 : FileMap) : FileMap :=
  let bias := fm1.source.endPos.byteIdx + 1
  let mappedPos := fm2.positions.map λ pos => { byteIdx := pos.byteIdx + bias }
  {
    source := s!"{fm1.source}\n{fm2.source}",
    positions := fm1.positions.take (fm1.positions.size - 1) ++ mappedPos
  }

/-- Add one command to the refactored file -/
def pushNewCommand (f : Format) : RefactorM Unit := do
  modify λ state@{ outContext := outContext@{ fileMap, .. }, .. } =>
    let payload := f.pretty
    let merged := mergeFileMap fileMap payload.toFileMap
    {
      state with outContext := {
        outContext with
        inputString := merged.source,
        fileMap := merged,
        endPos := merged.source.endPos,
        endPos_valid := by simp,
      }
    }
  -- After modification, run the parser ahead by one position
  let { outContext := inputCtx, outState, .. } ← get
  let (_endFlag, outState@ { commandState := { messages, .. }, .. }) ←
    Elab.Frontend.processCommand.run { inputCtx } |>.run outState
  -- Ensure no error has occurred
  if messages.hasErrors then
    let messages ← messages.toList.mapM (·.toString)
    throw (.userError s!"Error messages: {messages}")
  modify ({ · with outState })

/-- Run `FrontendM` at the tail of the out file -/
def liftFrontend { α } (x : FrontendM α) : RefactorM α := do
  let { outContext := inputCtx, outState, .. } ← get
  x.run {} |>.run { inputCtx } |>.run' outState
/-- Run `CoreM` at the tail of the out file -/
def runCoreM { α } (x : CoreM α) : RefactorM α := do
  liftFrontend $ runCommandElabM $ Elab.Command.liftCoreM x

def pushNewCommand' (command : Syntax.Command) : RefactorM Unit := do
  let f ← runCoreM do
    PrettyPrinter.ppCommand command
  pushNewCommand f

/-- runs a "frozen" `CommandElabM` that can't modify anything. -/
@[inline] protected
def Command.runCommandElabM (command : Command) (x : Elab.Command.CommandElabM α) : RefactorM α := do
  let inputCtx := (← read).inContext
  let cmdCtx : Elab.Command.Context := {
    fileName     := inputCtx.fileName
    fileMap      := inputCtx.fileMap
    snap?        := none,
    cancelTk?    := .none,
  }
  match (← liftM <| EIO.toIO' <| (x cmdCtx).run command.state) with
  | Except.error e      => throw <| IO.Error.userError s!"unexpected internal error: {← e.toMessageData.toString}"
  | Except.ok (a, _sNew) => return a

@[inline] protected
def Command.runCoreM { α } (command : Command) (c : CoreM α) : RefactorM α :=
  command.runCommandElabM $ Elab.Command.liftCoreM c

def constantDependencies (env : Environment) (name : Name) : NameSet :=
  let const := env.find? name |>.get!
  let s := const.type.getUsedConstantsAsSet
  let s := match const.value? with
    | .some v => s.append v.getUsedConstantsAsSet
    | .none => s
  s

def hasSorry (step : CompilationStep) : Bool :=
  step.trees.any λ tree =>
    let nodes := tree.filter λ
      | .ofTermInfo { expr, .. } => expr.isSorry
      | .ofTacticInfo { stx, .. } => stx.isOfKind `Lean.Parser.Tactic.tacticSorry
      | _ => false
    !nodes.isEmpty

/-- Scroll to the end of the file, reading all compilation units in the process  -/
def preprocess : FrontendM (List Command) := mapCompilationSteps λ step => do
  let constants ← step.newConstants
  let dependencies := constants.foldl (init := NameSet.empty) λ acc c =>
    acc.append $ constantDependencies step.after c
  let commandState := (← getThe Elab.Frontend.State).commandState
  let unit := if step.msgs.any (·.severity == .error) then
      {
        hasError := true,
        stx := step.stx,
        trees := step.trees,
        state := commandState,
        messages := step.msgs,
      }
    else
      {
        stx := step.stx,
        trees := step.trees,
        dependencies,
        constants,
        state := commandState,
        messages := step.msgs,
      }
  return unit

/-- Creates `combine (combine a[0] a[1]) a[2] ...` -/
def mkProdElem (combine : Name := ``And.intro) : List Expr → MetaM Expr
  | .nil => return .const `Unit []
  | [a] => return a
  | x :: xs => do
    let r ← mkProdElem combine xs
    Meta.mkAppM combine #[x, r]

private def mkDocComment (s : String) :=
  mkNode ``Parser.Command.docComment #[mkAtom "/--", mkAtom s!"{s} -/"]

protected def _root_.Array.last! { α } [Inhabited α] (a : Array α) : α :=
  match h : a.size with
  | 0 => default
  | n+1 => a[n]

def distilSearchTarget { α } (head : Command) (tail : List Command) (f : (Expr × Expr) → List (Expr × Expr) → Elab.Term.TermElabM α)
  : RefactorM α := do
  let (headName, witness, witnessValue) ← head.runCommandElabM do
    Elab.Command.liftCoreM do
      let env := head.state.env
      let name := head.constants.toList.head!
      let info := env.find? name |>.get!
      let .some value := info.value?
        | throwError s!"Constant has no value: {name}"
      return (name, ← normalize info.type, ← normalize value)
  let companions ← tail.mapM λ command => command.runCommandElabM do
    Elab.Command.liftTermElabM do
      let env := command.state.env
      let name := command.constants.toList.head!
      let info := env.find? name |>.get!
      let type ← normalize info.type
      let c ← mkConstWithLevelParams headName
      let type ← Meta.kabstract type c
      let .some value := info.value?
        | throwError s!"Constant has no value: {name}"
      -- Normalization strips away matchers.
      let value ← normalize value
      let value ← Meta.kabstract value c
      return (type, value)
  liftFrontend $ runCommandElabM $ Elab.Command.liftTermElabM do
    f (witness, witnessValue) companions
  where
  normalize (e : Expr) : CoreM Expr := do
    unfoldAuxLemmas $ ← unfoldMatchers e

/-- Fold `sorry`s into one definition -/
def foldTheoremsFlat (head : Command) (tail : List Command) : RefactorM Syntax.Command := do
  -- Concatenate all doc comments
  let allDocs := "\n".intercalate $ (head :: tail).filterMap λ command =>
    let `(docComment|$comment) := command.comments
    let s := comment.getDocString
    if s.isEmpty then .none else s
  let headName := head.constants.toList.head!
  let binderName := match headName with
    | .str _ binderName => Name.mkSimple binderName
    | _ => `x
  let coreOptions ← readCoreOptions
  distilSearchTarget head tail λ (witness, _) companions => do
    -- Construct the companion
    let companion ← Meta.withLocalDeclD binderName witness λ binder => do
      let companion ← mkProdElem ``And <| companions.map (·.fst.instantiate1 binder)
      Meta.mkLambdaFVars #[binder] companion
    let target ← Meta.mkAppOptM ``Subtype #[witness, companion]
    Meta.check target
    -- Delaborate this back into syntax
    let target ← withOptions (λ _ => pp.analyze.set coreOptions true) do
      PrettyPrinter.delab target
    let theoremIdent := mkIdent $ Name.mkSimple s!"{binderName}_composite"
    let comment? := if allDocs.isEmpty then .none else .some $ mkDocComment allDocs
    `(command|$[$comment?:docComment]? def $theoremIdent : $target := sorry)

structure DependencyTracker where
  -- Constants generated during the next batch of commands to be processed
  innerConstants : NameSet := {}

  isNonFlat : Bool := false
structure DependencyStructure where
  -- Commands which depend on each other
  component : List Command := []
  -- Intercalating commands
  intercalating : List Command := []
  -- Remainder
  tail : List Command
  -- Structure
  tracker : DependencyTracker

def extractDependencyStructure (head : Command) (commands : List Command)
  : RefactorM DependencyStructure := do
  let ((series, other), tracker) := (λ (z : StateM DependencyTracker _) => z.run {}) $
    commands.zipIdx.partitionM λ (command, _) => do
      let tracker ← get
      if command.dependencies.any tracker.innerConstants.contains then
        let innerConstants := command.constants.foldl
          (init := tracker.innerConstants)
          λ acc n => acc.insert n
        modify ({· with innerConstants, isNonFlat := true})
        return true
      if command.dependencies.any head.constants.contains then
        let innerConstants := command.constants.foldl
          (init := tracker.innerConstants)
          λ acc n => acc.insert n
        modify ({· with innerConstants })
        return true
      else
        return false
  if series.isEmpty then
    return {
      tail := commands,
      tracker,
    }
  -- Find all intercalating declarations
  let maxIdx := series.map Prod.snd |>.max?.get!
  let (intercalating, tail) := other.partition λ (_, idx) => idx < maxIdx
  return {
    component := series.map Prod.fst,
    intercalating := intercalating.map Prod.fst,
    tail := tail.map Prod.fst,
    tracker
  }

/-- Scroll one unit down from the top -/
def collectNextCommand : RefactorM Unit := do
  let { commands, .. } ← get
  let decl :: commands := commands | Refactor.fail "No commands left"
  modify ({ · with commands }) -- Prevents infinite loop

  let isSearchTarget ← decl.runCoreM do
    if decl.constants.isEmpty then
      return false
    let name := decl.constants.toList.head!
    let info := (← getEnv).find? name |>.get!
    let .some value := info.value? | return false
    return value.hasSorry
  if !isSearchTarget then
    pushNewCommand' (⟨decl.stx⟩ : Syntax.Command)
    return

  let depstr ← extractDependencyStructure decl commands
  if depstr.component.isEmpty then
    pushNewCommand' (⟨decl.stx⟩ : Syntax.Command)
    return

  if depstr.tracker.isNonFlat then
    Refactor.fail "Cannot refactor non-flat dependency structure"
  modify ({ · with commands := depstr.tail })

  -- Push all intercalating commands
  for command in depstr.intercalating do
    pushNewCommand' (⟨command.stx⟩ : Syntax.Command)

  let f ← foldTheoremsFlat decl depstr.component
  pushNewCommand' f

end Refactor

open Refactor in
def runRefactor (env : Environment) (source : String)
  (config : Refactor.Config := {}) (fileName := defaultFileName) : IO String := do
  let (fContext, fState) ← createContextStateFromFile source fileName env {}
  let commands ← preprocess.run {} |>.run fContext |>.run' fState
  let errors := commands.filter (·.hasError)
  if let .some error := errors.head? then
    let message ← error.messages.mapM (·.toString)
    throw $ IO.userError $ "\n".intercalate message
  let m : RefactorM Unit := do
    while !(← get).commands.isEmpty do
      collectNextCommand
  let outContext := {
    fContext.inputCtx with
    inputString := "",
    fileMap := "".toFileMap,
    endPos := "".endPos,
    endPos_valid := by simp,
  }
  let parserState := {}
  let outState := {
    commandState := Elab.Command.mkState env {} {},
    parserState,
    cmdPos := parserState.pos,
  }
  let (_, state) ← m.run { config, inContext := fContext.inputCtx }
    |>.run { outContext, outState, commands }
  return state.outContext.inputString

export Refactor (RefactorM)
