import enum, sys
from typing import Dict, List, Tuple
import pure
try: import sos.version as version
except: import version
try: from pyfiglet import Figlet
except: Figlet = None  # optional dependency
MARKER:str = r"/SOS/ "
try:
  from colorama import Fore
  MARKER_COLOR = Fore.WHITE + MARKER + Fore.RESET
  if sys.platform != "win32": MARKER = MARKER_COLOR  # override to use color even for log message on *n*x
except: MARKER_COLOR = MARKER  # if library not installed, use fallback

# Constants
APP:str = "Subversion Offline Solution"
APPNAME:str = APP + " V%s (C) Arne Bachmann" % version.__release_version__
VERSION:str = version.__version__
COMMAND:str = "sos"
del version  # unload module


Category = enum.Enum("Category", {
  "Repository_handling": 2,
  "Working_with_branches": 4,
  "Working_with_files": 6,
  "Defining_file_patterns": 8,
  "Configuration": 10,
  "Further_commands": 12
})

CategoryAbbrev:Dict[str,Category] = {
  "repo": Category.Repository_handling,
  "branches": Category.Working_with_branches,
  "files": Category.Working_with_files,
  "patterns": Category.Defining_file_patterns,
  "config": Category.Configuration,
  "other": Category.Further_commands
}

data Argument(name:str, long:str)  # command argument and description

data Command(category:Category, arguments:List[Argument], short:str, long:str)


COMMANDS:Dict[str,Command] = {
  "offline": Command(Category.Repository_handling,
    [
      Argument("[<branch-name>", "Name of the initial branch to use. Default: determined by the type of the underlying VCS"),
      Argument("[<message>] ]", "Initial commit message. Default: A timestamp")
    ],
    "Prepare working offline with SOS, creating an initial branch from the current file tree",
    """Creates the offline repository metadata in a folder ".sos/" relative to the current working directory.
       The existence of this metadata folder marks the root for the offline SOS repository"""
  ),
  "online": Command(Category.Repository_handling, [],
    "Finish working online, removing the SOS repository's metadata folder",
    """The user is warned if any branches remain that have not been committed/pushed to the underlying VCS.
       If not, or using the "--force"  option, the "./sos" folder is removed entirely.
       SOS will serve again only as a pass-through command for the original underlying VCS in that folder"""
  ),
  "dump": Command(Category.Repository_handling,
    [
      Argument("[<path>/]<name[.sos.zip]>", "File name for the exported repository archive dump")
    ],
    "Perform repository dump into an archive file",
    """The archive will contain only the metadata folder, not the file tree.
       After unzipping the archive, the file tree can be easily restored to the latest revision with "sos switch /" """
  ),
  "help": Command(Category.Further_commands,
    [
      Argument("[<command> | <category>]", """Name of command or command category to get help for.
                                              Command is one out of everything shown per "sos help".
                                              Category is one out of "repo", "branches", "files", "patterns", or "other".""")
    ],
    "Display usage and background information",
    """ The help command provides a compact usage and interface guide to SOS.
        For further information, read the online help at https://sos-vcs.net"""
  ),
  "version": Command(Category.Further_commands, [],
    "Display SOS version and packing information",
    """Show information about the SOS source revision, plus the version identifier of the PyPI package and Git commit status"""
  ),
  "log": Command(Category.Working_with_branches, [],
    "List revisions of current branch",
    """List all revisions of currently selected branch in chronological order (latest last).
       The format for each log entry is as follows:
       [*] r<revision> @<timestamp> (+<added files>/-<removed files>/~<modified files>/T<modified text files>) |<commit message or tag>|"""
  ),
  "status": Command(Category.Repository_handling,
    [
      Argument("[<branch>][/<revision>]", """Branch and/or revision to show changes of file tree against (if "useChangesCommand" flag is disabled).
                                             The argument is ignored (if "useChangesCommand" is enabled)""")
    ],
    "Display file tree changes or repository stats",
    """Display changed filepaths vs. last committed or specified revision on current or specified branch (if "useChangesCommand" flag is disabled).
       Display repository stats and settings (is "useChangesCommand" flag is enabled)"""
   ),
  "branch": Command(Category.Working_with_branches,
    [
      Argument("[<name>", "Name of the new branch to create"),
      Argument("[<message>] ]", "Initial commit message for the new branch")
    ],
    "Create a new branch",
    """Create a new branch and switch to work on it.
       Default: Use current file tree as basis, using automatically generated branch name and initial commit message."""
  ),
  "destroy": Command(Category.Working_with_branches,
    [
      Argument("[<branch>]", "Name or index of the branch")
    ],
    "Remove branch",
    """The current or specified branch will be removed entirely from the SOS metadata. There will be a backup, however, that can be restored manually"""
  ),
  "switch": Command(Category.Working_with_branches,
    [
      Argument("[<branch>][/<revision>]", "Branch and/or revision to switch to")
    ],
    "Switch to another branch",
    """Replace file tree contents by specified revision of current or specified other branch.
       This command will warn the user about changes in the file tree vs. the last committed revision on the current branch.
       After switching, all changes done to the file tree will be compared to the (new) current branch's latest committed revision"""   # check "file tree" wording
  ),
  "ls": Command(Category.Working_with_files,
    [
      Argument("[<folder path>]", "Path to list")
    ],
    "List files, display tracking status, and show changes",
    """Lists file in current or specified folder, showing modification status, and matching tracking patterns (if in track or picky mode)"""
  ),
  "commit": Command(Category.Working_with_files,
    [
      Argument("[<message>]", "Message to store with the revision. Can be used to refer to the revision, and is also shown in the ""log"" command")
    ],
    "Create a new revision",
    """Using the current file tree, create and persist a new revision of the current branch to the repository"""
  ),
  "update": Command(Category.Working_with_files,
    [
      Argument("[<branch>][/<revision>]", "Branch and/or revision")
    ],
    "Integrate work from another branch into the file tree",
    """Similarly to switch, this command updates the current file tree to the state of another revision, usually from another branch."
       In addition, it allows merging differing contents interactively or by rules specified through command-line switches.
       Default operation for files, lines and characters is add and remove"""
  ),
  "changes": Command(Category.Working_with_files,
    [
      Argument("[<branch>][/<revision>]", """Branch and/or revision to show changes of file tree against""")
    ],
    "Show inserted, removed, and/or modified files",
    """Checks the file tree for modifications against the last committed revision on the current branch.
       Additions are marked with ADD, deletions with DEL and modifications with MOD.
       Changes are detected by file size and modification timestamp (default), or by file size and file contents hash (repository created in strict mode)."""
  ),
  "diff": Command(Category.Working_with_files,
    [
      Argument("[<branch>][/<revision>]", ""),
    ],
    "List changes in file tree",
    """ (or `--from` specified revision) vs. last (or specified) revision"""
  ),
  "add": Command(Category.Working_with_files,
    [
      Argument("<file pattern>", "Folder-specific file name pattern"),
    ],
    "Add a tracking pattern",
    """Add a folder-specific file name tracking pattern to the current branch.
       Only applicable after "offline --track" or "offline --picky" """
  ),
  "addnot": Command(Category.Working_with_files,
    [
      Argument("<file pattern>", "Folder-specific file name pattern"),
    ],
    "Add a tracking pattern to the tracking blacklist",
    """Add a folder-specific file name tracking anti-pattern to the current branch"""
  ),
  "rm": Command(Category.Working_with_files,
    [
      Argument("<file pattern>", "Folder-specific file name pattern"),
    ],
    "Remove a tracking pattern",
    """Remove a folder-specific file name tracking pattern from the current branch.
       Only applicable after "offline --track" or "offline --picky" """
  ),
  "rmnot": Command(Category.Working_with_files,
    [
      Argument("<file pattern>", "Folder-specific file name pattern"),
    ],
    "Remove a tracking pattern from the tracking blacklist",
    """Remove a folder-specific file name tracking anti-pattern from the current branch."""
  ),
  "mv": Command(Category.Working_with_files,
    [
      Argument("<old file pattern>", "Folder-specific file name pattern"),
      Argument("<new file pattern>", "Folder-specific file name pattern"),
    ],
    "Rename, move, or move and rename tracked files according to the specified file patterns",
    """The command changes a tracking pattern and renames all tracked files that it matches.
       This command will also consider reordering of globs unless ambiguous, allowing for more complex rename actions"""
  ),
  "mvnot": Command(Category.Working_with_files,
    [
      Argument("<old file pattern>", "Folder-specific file name pattern"),
      Argument("<new file pattern>", "Folder-specific file name pattern"),
    ],
    "Modify a tracking pattern black list entry",
    """In contrast to "mv", this command won't affect any files, only the tracking pattern on the tracked files blacklist"""
  ),
  "config set": Command(Category.Configuration,
    [
      Argument("[<param>]", "Parameter name"),
      Argument("[<value>]", "Parameter value")
    ],
    "Define SOS setting",
    """For strings, flags, and numbers, this directly sets the value.
       For lists, the given string is split by semicola and defines the list's contenst.
       Use "config add" and "config rm" to modify single entries in a list configuration setting.

       Accepted values for flag-type parameters: 1/0, on/off, true/false, or yes/no"""
   ),
  "config unset": Command(Category.Configuration,
    [
      Argument("[<param>]", "Parameter name")
    ],
    "Clear SOS setting",
    """This completely removes the setting, letting SOS fall back to its default, if any.
       Removing the last entry from a list setting *will not* remove the setting itself, as this would have a different semantic
       (e.g. falling back to a default, or using the global setting instead of the local one)"""
   ),
  "config add": Command(Category.Configuration,
    [
      Argument("[<param>]", "Parameter name"),
      Argument("[<value>]", "Parameter value")
    ],
    "Add an entry to a list configuration setting",  # TODO clean wording config/setting/param
    """Adds one or more semicolon-separated values to a list-type configuration setting"""
   ),
  "config rm": Command(Category.Configuration,
    [
      Argument("[<param>]", "Parameter name"),
      Argument("[<value>]", "Parameter value")
    ],
    "Remove an entry from a list configuration setting",
    """Removes exactly one value from a list-type configuration setting"""
   ),
  "config show": Command(Category.Configuration,
    [
      Argument("[<name> | flags | texts | lists]", "Name of parameter or type of configuration parameters")
    ],
    "Shows SOS configuration",
    """Lists all configuration parameters, also indicating storage origin (local, global, or default).
       The optional argument is either the name of a parameter to display as the only output,
       or a parameter type to enumerate all valid SOS parameters for the specified configuration type"""
   ),
  "publish": Command(Category.Configuration,
    [
      Argument("[<message>]", "Commit message")
    ],
    "Commit the SOS branch to the underlying VCS",
    """This command uses the underlying VCS's "add" and "commit" commands to create a new revision with the changes made in the current SOS branch.
       The SOS branch will consequently be marked as clean (removing the dirty flag).
       In simple repository mode, the selection of files to commit is interactively, while in track and picky mode all files tracked or picked are committed."""
  ),
}

OPTIONS:Dict[str,Dict[str?,str]] = {  # option -> command (or None = for all) -> description
  "sos": {
    None: """Pass command and options to SOS, even when not offline, e.g. 'sos --sos config add texttype "*.md"'"""
    },
  "vcs": {
    None: "Pass command and options to underlying VCS, even in offline mode, e.g. ""sos --vcs add test.md"" for Git"
  },
  "compress": { "offline": """Compress all versioned files instead of simply copying them verbatim.
                 May be significantly slower, but reduces storage overhead.
                 Same as going offline after "sos config set compress on" """
  },
  "track": { "offline": """Defines repository mode to use SVN-like file handling per tracking patterns.
             Tracking patterns are added to or removed from a branch via "sos add" and "sos rm"; files can be moved and renamed via "sos mv" """
  },
  "picky": { "offline": """Defines repository mode to use Git-like file handling per staging patterns.
               Staging patterns are added to or removed from a branch via "sos add" and "sos rm"; files can be moved and renamed via "sos mv" """
  },
  "strict": {
    None: """Perform the command using file contents instead of relying on modification timestamp.
             File sizes are always compared in both modes""",
    "offline": """Defines repository property to always compare file contents instead of relying on modification timestamp.
                  File sizes are always compared in both modes.
                  Cannot be changed via user interface after repository creation.
                  Most commands, however, support a "--strict" option nevertheless"""  # TODO change if a repository properties command exists
  },
  "force": {
    None: """Executes potentially harmful operations, telling SOS that you really intend to perform that command.
             Most commands: Ignore uncommitted branches, continue to remove SOS repository metadata folders """,
    "offline": """If already in offline mode, remove offline repository first before creating empty offline repository anew""",
    "online": """Ignore uncommitted branches, continue to remove SOS repository metadata folder""",
    "destroy": """Ignore dirty branches (those with changes not committed back to the underlying VCS) and continue with branch destruction""",
    "switch": """Override safety check to break switching when file tree contains modifications""",
  },
  "full": {
    "dump": """Force a full repository dump instead of a differential export""",
  },
  "skip-backup": {
    "dump": "Don't create a backup of a previous dump archive before dumping the repository"""
  },
  "changes": {
    "log": "List differential changeset for each revision"
  },
  "diff": {
    "log": "Display textual diff for each revision"
  },
  "repo": {
    "status": """List branches and display repository status (regardless of "useChangesCommand" flag)"""
  },
  "stay": {
    "branch": "Perform branch operation, but don't switch to newly created branch"
  },
  "last": {
    "branch": "Use last revision instead of current file tree as basis for new branch. Doesn't affect current file tree"  # TODO avoid term "branching"
  },
  "fast": {
    "branch": "Use the experimental fast branch method. Always implies --last"
  },
  "meta": {
    "switch": "Only switch the branch's file tracking patterns when switching the branch. Won't update any files"
  },
  "progress": {
    None: """Display file names during file tree traversal, show processing speed, and show compression advantage, if the "compress" flag is enabled"""
  },
  "log": {
    None: """Configures the Python logging module to include source details like log level, timestamp, module, and line number with the logged messages"""
  },
  "verbose": {
    None: "Enable more verbose user output"
  },
  "debug": {
    None: "Enable logging of internal details (intended for developers only)"
  },
  "only <tracked pattern>": {
    None: """Restrict operation to specified already tracked tracking pattern(s). Available for commands "changes", "commit", "diff", "switch", and "update". Use --only several times to include more file patterns"""
  },
  "except <tracked pattern>": {
    None: """Avoid operation for specified already tracked tracking pattern(s)  . Available for commands "changes", "commit", "diff", "switch", and "update". Use --except several times to exclude more file patterns"""
  },
  "remote <file system path>": {
    "offline": """Add a secondary storage locations that replicates all repository metadata operations (use --remote several times for more remote locations)"""
  },
  "patterns": {
    "ls": "Only show tracking patterns"
  },
  "tags": {
    "ls": "List all repository tags (has nothing to do with file or filepattern listing)"
  },
  "recursive": {
    "ls": "Recursively list also files in sub-folders"
  },
  "r": {
    "ls": "Recursively list also files in sub-folders"
  },
  "all": {
    "ls": "Recursively list all files, starting from repository root",
    "log": """Show all commits since creation of the branch.
              Default is only showing the last "logLines" entries""",
    "publish": """Commit all files present at offline time, instead of only modifications thereafter.
                  When going offline with SOS on an underlying VCS checkout with modifications, use this option.
                  Otherwise - underlying VCS checkout was clean when going offline with SOS - avoid this option."""
  },
  "a": {
    "ls": "Recursively list all files, starting from repository root"
  },
  "tag": {
    "commit": "Store the commit message as a tag that can be used instead of numeric revisions"
  },
  "add": {
    "switch": "Only add new files"
  },
  "add-lines": {
    "switch": "Only add inserted lines"
  },
  "add-chars": {
    "switch": "Only add new characters"
  },
  "rm": {
    "switch": "Only remove vanished files"
  },
  "rm-lines": {
    "switch": "Only remove deleted lines"
  },
  "rm-chars": {
    "switch": "Only remove vanished characters"
  },
  "ask": {
    "switch": "Ask how to proceed with modified files"
  },
  "ask-lines": {
    "switch": "Ask how to proceed with modified lines"
  },
  "ask-chars": {
    "switch": "Ask how to proceed with modified characters"
  },
  "eol": {
    "switch": "Use EOL style from the integrated file instead. Default: EOL style of current file"
  },
#  "to": {  # or --from?
#    "diff": """Take "to" revision as target to compare against (instead of current file tree state)"""
#  },
  "ignore-whitespace": {
    "diff": "Ignore white spaces during comparison"  # -iw
  },
  "wrap": {
    "diff": "Wrap text around terminal instead of cropping into terminal width"
  },
  "soft": {
    "mv": "Do not move or rename files, only affect the tracking pattern"
  },
  "local": {
    "config set": "Persist configuration setting in local repository, not in user-global settings store"
  },
  "local": {
    "config unset": "Persist configuration setting in local repository, not in user-global settings store"
  },
  "local": {
    "config add": "Persist configuration setting in local repository, not in user-global settings store"
  },
  "local": {
    "config rm": "Persist configuration setting in local repository, not in user-global settings store"
  },
  "local": {
    "config show": "Only show configuration settings persisted in local repository, not from user-global settings store"
  },
  "prune": {
    "config rm": "Remove a list-type parameter together with the last entry"
  },
  "sos": {
    None: """Pass command and arguments to SOS, even when not in offline mode, e.g. "sos --sos config set key value" to avoid passing the command to Git or SVN"""
  },
  "n": {
    "log": """Maximum number of entries to show"""
  },
  "relative": {
    "changes": """Display paths relative to current working directory. Default: paths relative to SOS repository root""",
    "log":     """Display paths relative to current working directory. Default: paths relative to SOS repository root""",
    "switch":  """Display paths relative to current working directory. Default: paths relative to SOS repository root""",
    "add":     """Display pattern path relative to SOS repository root. Default: absolute file system path""",
    "remove":  """Display pattern path relative to SOS repository root. Default: absolute file system path"""
  }
}


def getTitleFont(text:str, width:int) -> Tuple[str,str] =
  ''' Finds best fitting font for termimal window width, falling back to SOS marker if nothing fits current terminal width. Returns (actual text, selected Figlet font). '''
  x:List[Tuple[int,str]] = sorted(t for t in [(max(len(_) for _ in Figlet(font = f, width = 999).renderText(text).split("\n")), f) for f in ["big", "modular", "bell", "nscript", "pebbles", "puffy", "roman", "rounded", "santaclara", "script", "small", "soft", "standard", "univers", "thin"]] if t[0] <= width)
  if len(x) == 0: text = MARKER  # replace by shortest text
  (text, sorted(t for t in [(max(len(_) for _ in Figlet(font = f, width = 999).renderText(text).split("\n")), f) for f in ["big", "modular", "bell", "nscript", "pebbles", "puffy", "roman", "rounded", "santaclara", "script", "small", "soft", "standard", "univers", "thin"]] if t[0] <= width)[-1][1])

def getTitle(large:bool = True) -> str?:  # https://github.com/pwaller/pyfiglet/blob/master/doc/figfont.txt
  ''' Large: use ascii-art. '''
  if not large: return APP
  if not Figlet: return None
  text, font = getTitleFont(APP, width = pure.termWidth)
  return "\n".join(_ for _ in Figlet(font = font, width = pure.termWidth).renderText(text).split("\n") if _.replace(" ", "") != "")

def usage(argument:str, version:bool = False, verbose:bool = False):
  if version:
    title:str? = getTitle()
    if title: print(title + "\n")
  print("%s%s%s" % (MARKER, APPNAME if version else APP, "" if not version else " (PyPI: %s)" % VERSION))
  if version: sys.exit(0)
  category:Category? = CategoryAbbrev.get(argument, None)  # convert shorthand for category
  command:str? = argument if category is None else None
  if command is None: print("\nUsage:\n  sos <command> [<argument1>, [<argument2>]] [<option1>, [<options...]]")
  for _value, cat in sorted([(_.value, _) for _ in list(Category)]) if category is None else [(None, category)]:  # over one or all categories
    ofcategory:Dict[str,Command] = {command_: values for command_, values in COMMANDS.items() if values.category == cat and (command is None or command_ == command)}  # select commands from chosen category
    if len(ofcategory) == 0: continue
    print("\n%s:" % cat.name.replace("_", " "))
    for name, cmd in sorted(ofcategory.items()):
      args:str = "  %s %s  " % (name, " ".join([c.name for c in cmd.arguments]))  # command argument names
      print("%s\n%s" % (args + cmd.short, pure.ajoin(" " * len(args), pure.splitStrip(cmd.long), nl = "\n")))
      if command is None and not verbose: continue  # TODO align commands correctly when in short mode
      if cmd.arguments: print("\n  Arguments:")
      maxlen:int = 4 + 2 + max(len(s.name) for s in cmd.arguments) if len(cmd.arguments) > 0 else 0  # argument name length max plus indentation
      for c in cmd.arguments: print(pure.ljust("    %s  " % c.name, maxlen) + ("\n" + pure.ljust(width = maxlen)).join(pure.splitStrip(c.long)))
      matchingoptions:List[Tuple[str, str[]]] = [(optname, pure.splitStrip(description)) for optname, description in [(optname, dikt[name]) for optname, dikt in OPTIONS.items() if name in dikt]]
      if matchingoptions:
        print("\n  Options:")
        maxoptlen:int = max([len(optname) for optname, __ in matchingoptions])
        for optname, descriptions in sorted(matchingoptions):
          if len(descriptions) == 0: continue
          print("    %s%s  %s%s" % ("--" if len(optname) > 1 else "-", pure.ljust(optname, maxoptlen +  (0 if len(optname) > 1 else 1)), descriptions[0], "\n" + pure.ajoin(" " * (6 + maxoptlen + (2 if len(optname) > 1 else 1)), descriptions[1:], nl = "\n") if len(descriptions) > 1 else ""))
      matchingoptions = [] if cmd is None else [(optname, pure.splitStrip(dikt[None]) if None in dikt else []) for optname, dikt in OPTIONS.items()]  # add all text for the generic description
      if matchingoptions:
        print("\n  Common options:")
        maxoptlen = max([len(optname) for optname, __ in matchingoptions])
        for optname, descriptions in sorted(matchingoptions):
          if len(descriptions) == 0: continue
          print("    %s%s  %s%s" % ("--" if len(optname) > 1 else "-", pure.ljust(optname, maxoptlen +  (0 if len(optname) > 1 else 1)), descriptions[0], "\n" + pure.ajoin(" " * (6 + maxoptlen + (2 if len(optname) > 1 else 1)), descriptions[1:], nl = "\n") if len(descriptions) > 1 else ""))
  if command is None:
    print("\nCommon options:")
    genericOptions:Dict[str,str] = {k: v[None] for k, v in OPTIONS.items() if None in v}
    maxlen = max(len(_) for _ in genericOptions)
    for optname, description in sorted(genericOptions.items()):
      print("  %s%s  %s" % ("--" if len(optname) > 1 else "-", pure.ljust(optname, maxlen), pure.ajoin(" " * (2 + 2 + maxlen + 2), pure.splitStrip(description), nl = "\n", first = False)))

# TODO wrap text at terminal boundaries automatically, if space suffices
#    [<branch>][/<revision>]      Revision string. Branch is optional (defaulting to current branch) and may be a label or number >= 0
#                                 Revision is an optional integer and may be negative to reference from the latest commits (-1 is most recent revision), or a tag name"""
  sys.exit(0)
