## Personal Time Keeper
## ====================
##
## Simple time keeping CLI

import algorithm, docopt, json, langutils, logging, os, nre, std/wordwrap,
  sequtils, sets, strutils, tempfile, terminal, times, uuids

import timeutils except `-`

import private/util
import private/api
import private/models
import private/version

#proc `$`*(mark: Mark): string =
  #return (($mark.uuid)[

proc exitErr(msg: string): void =
  fatal "ptk: " & msg
  quit(QuitFailure)

proc flexFormat(i: Duration): string =
  ## Pretty-format a time interval.

  let fmt =
    if i > initDuration(days = 1): "d'd' H'h' m'm'"
    elif i >= initDuration(hours = 1): "H'h' m'm'"
    elif i >= initDuration(minutes = 1): "m'm' s's'"
    else: "s's'"

  return i.format(fmt)

type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: Duration]

proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void =
  ## Write a nicely-formatted list of Marks to stdout.

  let marks = timeline.marks
  let now = getTime().local

  if indices.len == 0:
    writeLine(stdout, "No marks match the given criteria.")
    return

  var idxs = indices.sorted(
    proc(a, b: int): int = cmp(marks[a].time, marks[b].time))

  let largestInterval = now - marks[idxs.first].time
  let timeFormat =
    if largestInterval > initDuration(days = 365): "yyyy-MM-dd HH:mm"
    elif largestInterval > initDuration(days = 7): "MMM dd HH:mm"
    elif largestInterval > initDuration(days = 1): "ddd HH:mm"
    else: "HH:mm"

  var toWrite: seq[WriteData] = @[]

  var longestPrefix = 0

  for i in idxs:
    let
      interval: Duration =
        if (i == marks.len - 1): now - marks[i].time
        else: marks[i + 1].time - marks[i].time
      prefix =
        ($marks[i].id)[0..<8] & "  " & marks[i].time.format(timeFormat) &
        " (" & interval.flexFormat & ")"

    toWrite.add((
      idx: i,
      mark: marks[i],
      prefixLen: prefix.len,
      interval: interval))

    if prefix.len > longestPrefix: longestPrefix = prefix.len

  let colWidth = 80
  let notesPrefixLen = 4

  for w in toWrite:
    if w.mark.summary == STOP_MSG: continue

    setForegroundColor(stdout, fgBlack, true)
    write(stdout, ($w.mark.id)[0..<8])
    setForegroundColor(stdout, fgYellow)
    write(stdout, "  " & w.mark.time.format(timeFormat))
    setForegroundColor(stdout, fgCyan)
    write(stdout, " (" & w.interval.flexFormat & ")")
    resetAttributes(stdout)
    write(stdout, spaces(longestPrefix - w.prefixLen) & " -- " & w.mark.summary)

    if w.mark.tags.len > 0:
      setForegroundColor(stdout, fgGreen)
      write(stdout, " (" & w.mark.tags.join(", ") & ")")
      resetAttributes(stdout)

    writeLine(stdout, "")

    if includeNotes and len(w.mark.notes.strip) > 0:
      writeLine(stdout, "")
      let wrappedNotes = wrapWords(s = w.mark.notes,
                                  maxLineWidth = colWidth)
      for line in splitLines(wrappedNotes):
        writeLine(stdout, spaces(notesPrefixLen) & line)
      writeLine(stdout, "")

proc doInit(timelineLocation: string): void =
  ## Interactively initialize a new timeline at the given file path.

  stdout.write "Time log name [New Timeline]: "
  let name = stdin.readLine()

  let timeline = %*
    { "name": if name.strip.len > 0: name.strip else: "New Timeline",
      "marks": [] }
      #"createdAt": getLocalTime().format("yyyy-MM-dd'T'HH:mm:ss") }

  var timelineFile: File
  try:
    timelineFile = open(timelineLocation, fmWrite)
    timelineFile.write($timeline.pretty)
  finally: close(timelineFile)

type ExpectedMarkPart = enum Time, Summary, Tags, Notes

proc edit(mark: Mark): Mark =
  ## Interactively edit a mark using the editor named in the environment
  ## variable "EDITOR"

  result = mark

  var
    tempFile: File
    tempFileName: string

  try:
    (tempFile, tempFileName) = mkstemp("timestamp-mark-", ".txt", "", fmWrite)
    tempFile.writeLine(
      """# Edit the time, mark, tags, and notes below. Any lines starting with '#' will
# be ignored. When done, save the file and close the editor.""")
    tempFile.writeLine(mark.time.format(ISO_TIME_FORMAT))
    tempFile.writeLine(mark.summary)
    tempFile.writeLine(mark.tags.join(","))
    tempFile.writeLine(
      """# Everything from the line below to the end of the file will be considered
# notes for this timeline mark.""")
    tempFile.write(mark.notes)

    close(tempFile)
    tempFile = nil

    discard os.execShellCmd "$EDITOR " & tempFileName & " </dev/tty >/dev/tty"

    var markPart = Time
    var notes: seq[string] = @[]

    for line in lines tempFileName:
      if strip(line).len > 0 and strip(line)[0] == '#': continue
      elif markPart == Time: result.time = parseTime(line); markPart = Summary
      elif markPart == Summary: result.summary = line; markPart = Tags
      elif markPart == Tags:
        result.tags = line.split({',', ';'});
        markPart = Notes
      else: notes.add(line)

    result.notes = notes.join("\n")
  finally: close(tempFile)

proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int] =
  ## Filter down a set of marks according to options provided in command line
  ## arguments.

  let marks = timeline.marks
  let now = getTime().local
  let allIndices = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG).toHashSet
  let union = args["--or"]

  var selected =
    if union: initHashSet[int]()
    else: allIndices

  template filterMarks(curSet: HashSet[int], pred: untyped): untyped =
    var res: HashSet[int] = initHashSet[int]()
    if union:
      for mIdx {.inject.} in allIndices:
        if pred: res.incl(mIdx)
      res = res + curSet
    else:
      for mIdx {.inject.} in curSet:
        if pred: res.incl(mIdx)
    res

  if args["<firstId>"]:
    let idx = marks.findById($args["<firstId>"])
    if idx > 0: selected = selected.filterMarks(mIdx >= idx)

  if args["<lastId>"]:
    let idx = marks.findById($args["<lastId>"])
    if (idx > 0): selected = selected.filterMarks(mIdx <= idx)

  if args["--after"]:
    var startTime: DateTime
    try: startTime = parseTime($args["--after"])
    except: raise newException(ValueError,
      "invalid value for --after: " & getCurrentExceptionMsg())
    selected = selected.filterMarks(marks[mIdx].time > startTime)

  if args["--before"]:
    var endTime: DateTime
    try: endTime = parseTime($args["--before"])
    except: raise newException(ValueError,
      "invalid value for --before: " & getCurrentExceptionMsg())
    selected = selected.filterMarks(marks[mIdx].time < endTime)

  if args["--today"]:
    let b = now.startOfDay
    let e = b + 1.days
    selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)

  if args["--yesterday"]:
    let e = now.startOfDay
    let b = e - 1.days
    selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)

  if args["--this-week"]:
    let b = now.startOfWeek(dSun)
    let e = b + 7.days
    selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)

  if args["--last-week"]:
    let e = now.startOfWeek(dSun)
    let b = e - 7.days
    selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)

  if args["--tags"]:
    let tags = (args["--tags"] ?: "").split({',', ';'})
    selected = selected.filterMarks(tags.allIt(marks[mIdx].tags.contains(it)))

  if args["--remove-tags"]:
    let tags = (args["--remove-tags"] ?: "").split({',', ';'})
    selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it)))

  if args["--matching"]:
    let pattern = re("(?i)" & $(args["--matching"] ?: ""))
    selected = selected.filterMarks(marks[mIdx].summary.find(pattern).isSome)

  return sequtils.toSeq(selected.items).sorted(system.cmp)

when isMainModule:
 try:
  let doc = """
Usage:
  ptk init [options]
  ptk (add | start) [options]
  ptk (add | start) [options] <summary>
  ptk resume [options] [<id>]
  ptk amend [options] [<id>] [<summary>]
  ptk merge <timeline> [<timeline>...]
  ptk stop [options]
  ptk continue
  ptk delete <id>
  ptk (list | ls) [options]
  ptk (list | ls) tags
  ptk current
  ptk sum-time --ids <ids>...
  ptk sum-time [options] [<firstId>] [<lastId>]
  ptk serve-api <svcCfg> [--port <port>]
  ptk (-V | --version)
  ptk (-h | --help)

Options:

  -E --echo-args          Echo the program's understanding of it's arguments.
  -V --version            Print the tool's version information.
  -a --after <after>      Restrict the selection to marks after <after>.
  -b --before <before>    Restrict the selection to marks after <before>.
  -c --config <cfgFile>   Use <cfgFile> as configuration for the CLI.
  -e --edit               Open the mark in an editor.
  -f --file <file>        Use the given timeline file.
  -g --tags <tags>        Add the given tags (comma-separated) to the selected marks.
  -G --remove-tags <tagx> Remove the given tag from the selected marks.
  -h --help               Print this usage information.
  -m --matching <pattern> Restric the selection to marks matching <pattern>.
  -n --notes <notes>      For add and amend, set the notes for a time mark.
  -t --time <time>        For add and amend, use this time instead of the current time.
  -T --today              Restrict the selection to marks during today.
  -Y --yesterday          Restrict the selection to marks during yesterday.
  -w --this-week          Restrict the selection to marks during this week.
  -W --last-week          Restrict the selection to marks during the last week.
  -O --or                 Create a union from the time conditionals, not an intersection
                          (e.g. --today --or --yesterday)
  -v --verbose            Include notes in timeline entry output.
"""


  logging.addHandler(newConsoleLogger())
  let now = getTime().local

  # Parse arguments
  let args = docopt(doc, version = PTK_VERSION)

  if args["--echo-args"]: echo $args

  if args["--help"]:
    echo doc
    quit()

  # Find and parse the .ptkrc file
  let ptkrcLocations = @[
    if args["--config"]: $args["--config"] else:"",
    ".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]

  var ptkrcFilename: string =
    foldl(ptkrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")

  var cfg: JsonNode
  var cfgFile: File
  if not fileExists(ptkrcFilename):
    warn "ptk: could not find .ptkrc file."
    ptkrcFilename = $getEnv("HOME") & "/.ptkrc"
    try:
      cfgFile = open(ptkrcFilename, fmWrite)
      cfgFile.write("{\"timelineLogFile\": \"timeline.log.json\"}")
    except: warn "ptk: could not write default .ptkrc to " & ptkrcFilename
    finally: close(cfgFile)

  try: cfg = parseFile(ptkrcFilename)
  except: raise newException(IOError,
    "unable to read config file: " & ptkrcFilename &
    "\x0D\x0A" & getCurrentExceptionMsg())

  # Find the time log file
  let timelineLocations = @[
    if args["--file"]: $args["--file"] else: "",
    $getEnv("PTK_FILE"),
    cfg["timelineLogFile"].getStr(""),
    "ptk.log.json"]

  var timelineLocation =
    foldl(timelineLocations, if len(a) > 0: a elif fileExists(b): b else: "")

  # Execute commands
  if args["init"]:
    doInit(foldl(timelineLocations, if len(a) > 0: a else: b))

  elif args["merge"]:

    let filesToMerge = args["<timeline>"]
    let timelines = filesToMerge.mapIt(loadTimeline(it))

    let names = timelines.mapIt(it.name).toHashSet
    let mergedName = sequtils.toSeq(names.items).foldl(a & " + " & b)
    var merged: Timeline = (
      name: mergedName,
      marks: @[])

    for timeline in timelines:
      for mark in timeline.marks:
        var existingMarkIdx = merged.marks.findById($mark.id)
        if existingMarkIdx >= 0:
          if merged.marks[existingMarkIdx].summary != mark.summary:
            merged.marks[existingMarkIdx].summary &= " | " & mark.summary
          if merged.marks[existingMarkIdx].notes != mark.notes:
            merged.marks[existingMarkIdx].notes &= "\r\n--------\r\b" & mark.notes

        else: merged.marks.add(mark)

    writeLine(stdout, pretty(%merged))

  else:

    if not fileExists(timelineLocation):
      raise newException(IOError,
        "time log file doesn't exist: " & timelineLocation)

    var timeline = loadTimeline(timelineLocation)

    if args["stop"]:

      if timeline.marks.last.summary == STOP_MSG:
        echo "ptk: no current task, nothing to stop"
        quit(0)

      let newMark: Mark = (
        id: genUUID(),
        time: if args["--time"]: parseTime($args["--time"]) else: now,
        summary: STOP_MSG,
        notes: args["--notes"] ?: "",
        tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isEmptyOrWhitespace))

      timeline.marks.add(newMark)

      timeline.writeMarks(
        indices = @[timeline.marks.len - 2],
        includeNotes = args["--verbose"])
      echo "ptk: stopped timer"

      saveTimeline(timeline, timelineLocation)

    if args["continue"]:

      if timeline.marks.last.summary != STOP_MSG:
        echo "ptk: there is already something in progress:"
        timeline.writeMarks(
          indices = @[timeline.marks.len - 1],
          includeNotes = args["--verbose"])
        quit(0)

      let prevMark = timeline.marks[timeline.marks.len - 2]
      var newMark: Mark = (
        id: genUUID(),
        time: if args["--time"]: parseTime($args["--time"]) else: now,
        summary: prevMark.summary,
        notes: prevMark.notes,
        tags: prevMark.tags)

      timeline.marks.add(newMark)
      timeline.writeMarks(
        indices = @[timeline.marks.len - 1],
        includeNotes = args["--verbose"])

      saveTimeline(timeline, timelineLocation)

    if args["add"] or args["start"]:

      var newMark: Mark = (
        id: genUUID(),
        time: if args["--time"]: parseTime($args["--time"]) else: now,
        summary: args["<summary>"] ?: "",
        notes: args["--notes"] ?: "",
        tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isEmptyOrWhitespace))

      if args["--edit"]: newMark = edit(newMark)

      let prevLastIdx = timeline.marks.getLastIndex()
      timeline.marks.add(newMark)
      timeline.writeMarks(
        indices = if prevLastIdx < 0: @[0]
                  else: @[prevLastIdx, timeline.marks.len - 1],
        includeNotes = args["--verbose"])

      saveTimeline(timeline, timelineLocation)

    if args["resume"]:

      var markToResumeIdx: int

      if args["<id>"]:
        markToResumeIdx = timeline.marks.findById($args["<id>"])
        if markToResumeIdx == -1: exitErr "Cannot find a mark matching " & $args["<id>"]
      else:
        markToResumeIdx = timeline.marks.getLastIndex()
        if markToResumeIdx < 0: exitErr "No mark to resume."
      var markToResume = timeline.marks[markToResumeIdx]

      var newMark: Mark = (
        id: genUUID(),
        time: if args["--time"]: parseTime($args["--time"]) else: now,
        summary: markToResume.summary,
        notes: markToResume.notes,
        tags: markToResume.tags)

      if args["--edit"]: newMark = edit(newMark)

      timeline.marks.add(newMark)
      timeline.writeMarks(
        indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
        includeNotes = args["--verbose"])

      saveTimeline(timeline, timelineLocation)

    if args["amend"]:

      # Note, this returns a copy, not a reference to the mark in the seq.
      var markIdx: int

      if args["<id>"]:
        markIdx = timeline.marks.findById($args["<id>"])
        if markIdx == -1: exitErr "Cannot find a mark matching " & $args["<id>"]
      else:
        markIdx = timeline.marks.getLastIndex()
        if markIdx < 0: exitErr "No mark to amend."

      var mark = timeline.marks[markIdx]

      if args["<summary>"]: mark.summary = $args["<summary>"]
      if args["--notes"]: mark.notes = $args["<notes>"]
      if args["--tags"]:
        mark.tags &= (args["--tags"] ?: "").split({',', ';'})
        mark.tags = mark.tags.deduplicate
      if args["--remove-tags"]:
        let tagsToRemove = (args["--remove-tags"] ?: "").split({',', ';'})
        mark.tags = mark.tags.filter(proc (t: string): bool =
          anyIt(tagsToRemove, it == t))
      if args["--time"]:
        try: mark.time = parseTime($args["--time"])
        except: raise newException(ValueError,
          "invalid value for --time: " & getCurrentExceptionMsg())

      if args["--edit"]: mark = edit(mark)

      timeline.marks.delete(markIdx)
      timeline.marks.insert(mark, markIdx)

      timeline.writeMarks(
        indices = @[markIdx],
        includeNotes = args["--verbose"])


      saveTimeline(timeline, timelineLocation)

    if args["delete"]:

      let markIdx = timeline.marks.findById($args["<id>"])
      timeline.marks.delete(markIdx)
      saveTimeline(timeline, timelineLocation)

    if args["list"] or args["ls"]:

      if args["tags"]:

        echo $(timeline.marks.mapIt(it.tags)
                .flatten().deduplicate().sorted(system.cmp).join("\n"))

      else:
        var selectedIndices = timeline.filterMarkIndices(args)

        timeline.writeMarks(
          indices = selectedIndices,
          includeNotes = args["--verbose"])

    if args["current"]:

      let idx = timeline.marks.len - 1
      if timeline.marks[idx].summary == STOP_MSG:
        echo "ptk: no current task"
      else:
        timeline.writeMarks(
          indices = @[idx],
          includeNotes = true)

    if args["sum-time"]:

      var intervals: seq[Duration] = @[]

      if args["--ids"]:
        for id in args["<ids>"]:
          let markIdx = timeline.marks.findById(id)
          if markIdx == -1:
            warn "ptk: could not find mark for id " & id
          elif markIdx == timeline.marks.len - 1:
            intervals.add(now - timeline.marks.last.time)
          else:
            intervals.add(timeline.marks[markIdx + 1].time - timeline.marks[markIdx].time)

      else:

        var indicesToSum = timeline.filterMarkIndices(args)

        for idx in indicesToSum:
          let mark = timeline.marks[idx]
          if idx == timeline.marks.len - 1: intervals.add(now - mark.time)
          else: intervals.add(timeline.marks[idx + 1].time - mark.time)

      if intervals.len == 0:
        echo "ptk: no marks found"

      else:
        let total = intervals.foldl(a + b)
        echo flexFormat(total)

    if args["serve-api"]:

      if not fileExists($args["<svcCfg>"]):
        exitErr "cannot find service config file: '" & $args["<svcCfg>"]

      var apiCfg = loadApiConfig(parseFile($args["<svcCfg>"]))
      if args["--port"]: apiCfg.port = parseInt($args["--port"])

      start_api(apiCfg)

 except:
  fatal "ptk: " & getCurrentExceptionMsg()
  quit(QuitFailure)