"""The typerconf module and config subcommand"""

import appdirs
import io
import json
import logging
import os
import sys
import typer
import typing

normalized_path = os.path.normpath(sys.argv[0])
basename = os.path.basename(normalized_path)
dirs = appdirs.AppDirs(basename)

class Config:
  """Navigates nested JSON structures by dot-separated addressing."""

  def __init__(self, json_data={}):
    """
    Constructs a config object to navigate from JSON data `json_data`.
    """
    self.__data = json_data

  def get(self, path: str = "") -> typing.Any:
    """
    Returns object at `path`.
    Example:
    - `path = "courses.datintro22.url"` and
    - Config contains `{"courses": {"datintro22": {"url": "https://..."}}}` 
      will return "https://...".

    Any part of the path that can be converted to an integer, will be converted 
    to an integer. This way we can access elements of lists too.
    """
    structure = self.__data

    if not path:
      return structure

    for part in path.split("."):
      try:
        part = int(part)
      except ValueError:
        pass
      try:
        structure = structure[part]
      except KeyError:
        raise KeyError(f"{part} along {path} doesn't exist")

    return structure

  def set(self, path: str, value: typing.Any):
    """
    Sets `value` at `path`. Any parts along the path that don't exist will be 
    created.

    Example:
    - `value = "https://..."`,
    - `path = "courses.datintro22.url"`
    will create `{"courses": {"datintro22": {"url": "https://..."}}}`.

    Any part of the path that can be converted to an integer, will be converted 
    to an integer. This way we can access elements of lists too. However, we 
    cannot create index 3 in a list if it doesn't exist (we can't expand 
    lists).

    If `value` is `None`, the entry at `path` will be deleted, if it exists.
    """
    structure = self.__data

    parts = path.split(".")

    for part in parts[:-1]:
      try:
        part = int(part)
      except ValueError:
        pass
      try:
        structure = structure[part]
      except KeyError:
        if value is None:
          return
        else:
          structure[part] = {}
          structure = structure[part]

    part = parts[-1]
    try:
      part = int(part)
    except ValueError:
      pass

    if value is None:
      try:
        del structure[part]
      except KeyError:
        pass
    else:
      structure[part] = value
  def paths(self, from_root=""):
    """
    Returns all existing paths.

    The optional argument `from_root` is a path and the method return all 
    subpaths rooted at that point.
    """
    paths = []
    root = self.get(from_root)

    if isinstance(root, dict):
      for part in root:
        if from_root:
          path = f"{from_root}.{part}"
        else:
          path = part

        paths.append(path)
        paths += self.paths(from_root=path)
    elif isinstance(root, list):
      paths += [f"{from_root}.{x}" for x in range(len(root))]

    return paths
  def read_config(self, conf_file=f"{dirs.user_config_dir}/config.json"):
    """
    Reads the config data structure (JSON) into the Config object.
    `conf_file` is an optional argument providing one of the following:
    - an already opened file object (anything derived from `io.IOBase`);
    - anything that `open` can handle, for instance:
      - a string, which is the path to the config file;
      - an integer, which is a file descriptor (see `os.open`).

    Errors:

    - The first is that the file doesn't exist ([[FileNotFoundError]]).
    - There is also a related one, [[NotADirectoryError]]. The problem of 
      [[NotADirectoryError]] occurs when a file on the path is used as a directory 
      --- but only for reading, when trying to create a directory hierarchy, it 
      will yield [[FileExistsError]]. We can't recover from this error, as an 
      attempt to write to the file will also fail.
    - Finally, the file exists, but it's not proper JSON ([[JSONDecodeError]]).
    """
    if isinstance(conf_file, io.IOBase):
      self.__data = json.load(conf_file)
    else:
      try:
        with open(conf_file) as conf_file:
          self.__data = json.load(conf_file)
          return
      except FileNotFoundError as err:
        raise FileNotFoundError(f"Config file {conf_file} could not be found: {err}")
      except NotADirectoryError as err:
        raise NotADirectoryError(f"A part of the path is a file, "
                                 f"but used as directory: {err}")
      except json.decoder.JSONDecodeError as err:
        raise ValueError(f"Config file {conf_file} "
                         f"could not be decoded: {err}")
  def write_config(self, conf_file=f"{dirs.user_config_dir}/config.json"):
    """
    Stores the config data (as JSON) in the config file.
    `conf_file` is an optional argument providing one of the following:
    - an already opened file object (anything derived from `io.IOBase`);
    - anything that `open` can handle, for instance:
      - a string, which is the path to the config file;
      - an integer, which is a file descriptor (see `os.open`).
    """
    if isinstance(conf_file, io.IOBase):
      json.dump(self.__data, conf_file)
    else:
      conf_dir = os.path.dirname(conf_file)
      if not os.path.isdir(conf_dir):
        os.makedirs(conf_dir)

      with open(conf_file, "w") as conf_file:
        json.dump(self.__data, conf_file)
def add_config_cmd(cli: typer.Typer):
  """
  Add config command to Typer cli
  """
  path_arg = typer.Argument(...,
                            help="Path in config, e.g. 'courses.datintro22'. "
                                 "Empty string is root of config.",
                            shell_complete=complete_path_callback)
  value_arg = typer.Option([], "-s", "--set",
                           help="Values to store. "
                                "More than one value makes a list. "
                                "Values are treated as JSON if possible.")

  @cli.command(name="config")
  def config_cmd(path: str = path_arg,
                 values: typing.List[str] = value_arg):
    """
    Reads values from or writes values to the config.
    """
    if values:
      if len(values) == 1:
        values = values[0]
      set(path, values)
    else:
      print_config(get(path), path)
def get(path: str = "") -> typing.Any:
  """
  Returns the value stored at `path` in the config.

  By default, `path = ""`, which returns the entire configuration as a Config 
  object.
  """
  conf = Config()
  conf.read_config()
  return conf.get(path)

def set(path: str, value: typing.Any):
  """
  Sets `value` at `path` in the config. `value` will be interpreted as JSON, if 
  conversion to JSON fails, it will be used as is.

  If `value` is `None`, the entry referenced by `path` will be deleted, if it 
  exists.
  """
  conf = Config()
  conf.read_config()
  conf.set(path, value)
  conf.write_config()
def complete_path(initial_path: str, conf: Config = None):
  """
  Returns all valid paths in the config starting with `initial_path`.
  If `conf` is not None, use that instead of the actual config.
  """
  if not conf:
    conf = Config(get())

  return list(filter(lambda x: x.startswith(initial_path),
                     conf.paths()))
def complete_path_callback(initial_path: str):
  return complete_path(initial_path)
def print_config(conf, path=""):
  """
  Prints the config tree contained in `conf` to stdout.
  Optional `path` is prepended.
  """
  try:
    for key in conf.keys():
      if path:
        print_config(conf[key], f"{path}.{key}")
      else:
        print_config(conf[key], key)
  except AttributeError:
    print(f"{path} = {conf}")


def main():
  cli = typer.Typer()
  add_config_cmd(cli)
  cli()

if __name__ == "__main__":
  main()
