#!/usr/bin/env python

import socket
import yaml, sys, subprocess, os
import concurrent.futures
import re
import shutil
import json
import time
from pathlib import Path
import os

def get_latest_git_mtime(repos: dict) -> float:
  max_mtime = 0.0
  for k, info in repos.items():
    repo_path = os.path.expanduser(info.get("path"))
    if not repo_path:
      continue
    git_dir = os.path.join(repo_path, ".git")
    try:
      mtime = os.path.getmtime(git_dir)
      max_mtime = max(max_mtime, mtime)
    except (FileNotFoundError, NotADirectoryError):
      continue
  return max_mtime

def is_repo_status_outdated(repos):
  latest_mtime = get_latest_git_mtime(repos)
  last_mtime = 0
  if os.path.exists(filepath("status")) and os.path.exists(filepath("mtime")):
    with open(filepath("mtime"), "rt") as fin:
      content = fin.read().strip()
      last_mtime = float(content)
  return latest_mtime > last_mtime

def to_keycap(ch: str) -> str:
  # this looked crap on my remote sessions for some reason...
  # if ch in '0123456789#*':
  #   return ch + '\uFE0F\u20E3'
  # else:
  #   raise ValueError(f"Unsupported keycap character: {ch!r}")
  return ch

def indicators(props, ind = "tl"):
  tl               = "\U0001F7E2"
  if props["ab"]["push"] + props["ab"]["pull"] > 0:
    tl             = "\U0001F7E1"
  if props["n"]                                > 0:
    tl             = "\U0001F534"
  unstaged         = "\U0001F4DD"
  up_keycap        = "\U0001F53C" # "\u2B06\uFE0F"
  down_keycap      = "\U0001F53D" # "\u2B07\uFE0F"
  status_digit     = "#" if props["n"] > 9 else str(props["n"] % 10)
  status_indicator = f"{to_keycap(status_digit)}{unstaged}"
  push_digit       = "#" if props["ab"]["push"] > 9 else str(props["ab"]["push"] % 10)
  push_indicator   = f"{to_keycap(push_digit)}{up_keycap}"
  pull_digit       = "#" if props["ab"]["pull"] > 9 else str(props["ab"]["pull"] % 10)
  pull_indicator   = f"{to_keycap(pull_digit)}{down_keycap}"
  ret              = "?"
  if ind == "tl":
    ret            = f"{tl}"
  if ind == "flags":
    ret            = f"{status_indicator} {push_indicator} {pull_indicator}"
  return             ret

def rm(p):
  if os.path.exists(p):
    os.remove(p)

def filepath(f):
  base = os.environ.get("XDG_CACHE_HOME")
  base = "/tmp" if base == None else base
  ret = os.path.join(base, f"repo.{f}")
  return ret

def is_file_recent(filepath, minutes=10):
  if not os.path.isfile(filepath):
    return False
  mtime = os.path.getmtime(filepath)
  return (time.time() - mtime) < (minutes * 60)

def hostname():
  ret = socket.gethostname().split('.', 1)[0]
  return ret

def parse_yaml(file_path):
  with open(file_path, 'r') as file:
    try:
      data = yaml.safe_load(file)
      return data
    except e:
      print(f"Error parsing YAML file: {e}")
      return None

def execute_command(command, cwd = None, dump_error = True):
  command = f"/bin/bash -c '{command}'"
  try:
    if cwd != None:
      result = subprocess.run(command, cwd=cwd, shell=True, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    else:
      result = subprocess.run(command, shell=True, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if result.returncode == 0:
      return result.stdout.strip()
    else:
      print(f"Error executing command: {result.stderr.strip()}")
      print(command)
      return None
  except subprocess.CalledProcessError as e:
    if dump_error:
      print(f"Error executing command: {e.stderr.strip()}")
      print(command)
    return None

def isFossil(cfg):
  ret = False
  if "vcs" in cfg.keys():
    ret = ( cfg["vcs"] == "fossil" )
  return ret

def perform_action(action, key, item, silent = False):
  output = None
  hn = hostname()
  lbl = "{0}ing".format(action).title()
  if os.path.exists(os.path.expanduser(item["path"])):
    if action in list([ "pull", "push", "fetch" ]):
      push = True
      if "push" in item.keys():
        push = item["push"]
      if push or action in list([ "pull", "fetch" ]):
        if isFossil(item):
          cwd = Path(os.path.expanduser(item["path"]))
          cmd = f"fossil {action}"
          if not silent:
            print("{0} {1}...".format(lbl, key))
          output = execute_command(cmd, cwd=cwd)
        else:
          cmd = "git -C \"{0}\" {1}".format(os.path.expanduser(item["path"]), action)
          if not silent:
            print("{0} {1}...".format(lbl, key))
          output = execute_command(cmd)
      rm(filepath("status"))
    elif action == "sync":
      if not silent:
        print("{0} {1}...".format(lbl, key))
      perform_action("pull", key, item, silent=True)
      perform_action("push", key, item, silent=True)
      rm(filepath("status"))
    elif action == "lcs":
      if not silent:
        print("{0} {1}...".format(lbl, key))
      perform_action("pull", key, item, silent=True)
      perform_action("lc", key, item, silent=True)
      perform_action("pull", key, item, silent=True)
      perform_action("push", key, item, silent=True)
      rm(filepath("status"))
    elif action == "lc":
      if isFossil(item):
        cwd = Path(os.path.expanduser(item["path"]))
        cmd = f"fossil add ."
        output = execute_command(cmd, cwd=cwd)
        cmd = f"fossil commit -m \"Lazy commit on {hn}\""
        output = execute_command(cmd, cwd=cwd)
      else:
        cmd = "git -C \"{0}\" status --porcelain".format(os.path.expanduser(item["path"]))
        output = execute_command(cmd).split("\n")
        if len(output[0]) > 0:
          print("Lazy committing {0}...".format(key))
          cmd = "git -C \"{0}\" add .".format(os.path.expanduser(item["path"]))
          output = execute_command(cmd)
          cmd = "git -C \"{0}\" commit -m \"Lazy commit on {1}.\"".format(os.path.expanduser(item["path"]), hn)
          output = execute_command(cmd)
      rm(filepath("status"))
  return output

if __name__ == "__main__":
  yaml_file_path = os.getenv("REPO_CFG")
  if yaml_file_path == None or not os.path.exists(yaml_file_path):
    print(f"Environment variable REPO_CFG needs to point to your repo.yaml file.", file=sys.stderr)
    sys.exit(1)
  try:
    cfg = parse_yaml(yaml_file_path)
  except:
    print(f"Unable to parse {yaml_file_path}.", file=sys.stderr)
    sys.exit(2)
  r = list(cfg.keys())
  config_file = filepath("config")
  if not is_file_recent(config_file, minutes = 60 * 60):
    # Let's quickly sweep through and expand any tildes ("~") in the path references...
    for k in r:
      path = os.path.expanduser(cfg[k]["path"])
      cmd = f"git config --global --add safe.directory {path}"
      # I am not outputting any errors here because of a dumb bug in WSL.
      output = execute_command(cmd, False)
    Path(config_file).touch()
  if len(sys.argv) == 3:
    if not sys.argv[2] in cfg.keys():
      print("{0} is not one of your repositories.".format(sys.argv[2]))
      exit(1)
    r = list([ sys.argv[2] ])
  if sys.argv[1] == "list":
    for k in cfg.keys():
      print(k)
  elif sys.argv[1] == "status":
    dat = dict()
    cache_file = filepath("status")
    if is_repo_status_outdated(cfg):
      for k in r:
        path = os.path.expanduser( cfg[k]["path"] )
        if os.path.exists( path ):
          n = 0
          if isFossil(cfg[k]):
            cwd = os.path.expanduser(cfg[k]["path"])
            cmd = "fossil json status"
            output = json.loads(execute_command(cmd))
            n = len(output["payload"]["files"])
            cmd = "fossil extras"
            n = n + len(execute_command(cmd).split("\n"))
          else:
            cmd = "git -C \"{0}\" status --porcelain=2 --branch".format( path )
            props = dict()
            props["key"] = k
            lines = execute_command(cmd).split("\n")
            for line in lines:
              m = re.match(r"# branch\.(\S+)\s+(.+)$", line)
              if m:
                prop = m.group(1)
                rest = m.group(2)
                if prop == "ab":
                  m2 = re.match(r"[+](\d+)\s[-](\d+)", rest)
                  p2 = rest
                  if m2:
                    p2 = dict()
                    p2["push"] = int(m2.group(1))
                    p2["pull"] = int(m2.group(2))
                    props[prop] = p2
                  props[prop] = p2
                elif prop == "oid":
                  props["commit"] = rest
                else:
                  props[prop] = rest
              else:
                n = n + 1
          props["n"] = n
          dat[k] = props
      with open(cache_file, "wt") as fout:
        json.dump(dat, fout, indent = 2)
      with open(filepath("mtime"), "wt") as fout:
        fout.write(str(get_latest_git_mtime(cfg)))
    else:
      with open(cache_file, "rt") as fin:
        dat = json.load(fin)
    ml = 0
    for k in dat:
      ml = len(k) if len(k) > ml else ml
    for k in dat:
      tl    = indicators(dat[k], "tl")
      flags = indicators(dat[k], "flags")
      print(f" {tl} {k.ljust(ml + 1)} {flags}")
    # print(json.dumps(dat, indent = 2))
  elif sys.argv[1] in list( [ "sync", "lc", "lcs", "pull", "push", "fetch" ] ):
    thread_count = 10
    with concurrent.futures.ThreadPoolExecutor(max_workers=thread_count) as executor:
      futures = {executor.submit(perform_action, sys.argv[1], k, cfg[k]) for k in r}
      for future in concurrent.futures.as_completed(futures):
        future.result()
        try:
          future.result()  # To get the result of sync if it returns something
        except Exception as exc:
          print(f"{exc}")
    # for k in r:
    #   perform_action(sys.argv[1], k, cfg[k])
  elif sys.argv[1] == "clone":
    hn = hostname()
    for k in r:
      hosttest = True
      if "hosts" in cfg[k].keys():
        hosttest = hn in cfg[k]["hosts"]
      if hosttest and not os.path.exists(os.path.expanduser(cfg[k]["path"])):
        print("Cloning {0} into {1}...".format(k, os.path.expanduser(cfg[k]["path"])))
        if isFossil(cfg[k]):
          cwd = Path(os.path.expanduser(cfg[k]["path"])).parent
          cmd = "fossil clone --save-http-password \"{0}\"".format(cfg[k]["url"])
          output = execute_command(cmd, cwd=cwd)
          with open(os.path.expanduser(f"{cfg[k]['path']}.password"), "wt") as fout:
            fout.write(output)
        else:
          cmd = "git clone \"{0}\" \"{1}\"".format(cfg[k]["url"], os.path.expanduser(cfg[k]["path"]))
          output = execute_command(cmd)
  elif sys.argv[1] == "align":
    hn = hostname()
    p = re.compile("^.*Fetch URL: (.*)$")
    for k in r:
      hosttest = True
      if "hosts" in cfg[k].keys():
        hosttest = hn in cfg[k]["hosts"]
      if hosttest and os.path.exists(os.path.expanduser(cfg[k]["path"])):
        print("Aligning {0}...".format(k))
        cmd = "git -C \"{0}\" remote show -n origin".format(os.path.expanduser(cfg[k]["path"]))
        output = execute_command(cmd)
        url = None
        for line in output.split("\n"):
          r = p.match(line)
          if r != None:
            url = r.group(1)
        if url == None:
          print("Unable to determine origin's remote path.")
        else:
          if cfg[k]["url"] == url:
            print("  🟢 {0}".format(url))
          else:
            print("  🔴 {0}".format(url))
            cmd = "git -C \"{0}\" remote set-url origin \"{1}\"".format(os.path.expanduser(cfg[k]["path"]), cfg[k]["url"])
            output = execute_command(cmd)
            print("  🟢 {0}".format(cfg[k]["url"]))
      # else:
      #   print("Failed hosttest for {0}".format(k))
  elif sys.argv[1] == "prune":
    hn = hostname()
    for k in r:
      hosttest = True
      if "hosts" in cfg[k].keys():
        hosttest = hn in cfg[k]["hosts"]
      if not hosttest and os.path.exists(os.path.expanduser(cfg[k]["path"])):
        perform_action("lc", k, cfg[k])
        perform_action("sync", k, cfg[k])
        print("Pruning {0}".format(k))
        shutil.rmtree(os.path.expanduser(cfg[k]["path"]))

