#!/usr/bin/env python3
"""xBrowserSync json backup editor"""

__version__ = "v1.0 200330"

#==========================================================
#
#  Chris Nelson  2018 - 2019
#
# 200330 v1.0   Merged pull request #3 from mblais (add unbuffered getch input; add 'all' option), Reworked --tags and --names switches.
# 191206 v0.3   Added tags dump
# 191205 v0.2   Updated to xbrowsersync v1.5 and Python 3.x ONLY
# 181128 v0.1   New
#
# Issues, and changes pending
#   None
#
#==========================================================

import argparse
import os.path
import json
import codecs
import sys
import io


# Configs / Constants
INDENT = 2
OFILESUFFIX = "_OUT"
NONE_SEARCH = "__NONE__"

# Global items
url_dict = {}
folder_dict = {}
tags_dict = {}


def main():
    global match_cnt
    global change_cnt
    global do_all
    global json_dict
    global url_dict
    global folder_dict
    global tags_dict

    with io.open(args.Infile, encoding='utf8', errors="replace") as json_data:
        json_dict = json.load(json_data)

    term = NONE_SEARCH

    if args.print:
        digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="printtree")
        exit()
    if args.tags > -1:
        digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="GatherTags")
        for tag in sorted(tags_dict.keys()):
            if args.tags == 0:
                if len(tags_dict[tag]) == 1:
                    print ("[{}] (1):".format(tag))
                    if args.names:
                        print ("    {:<4} - {}".format(tags_dict[tag][0]["id"], tags_dict[tag][0]["title"]))
                        print()
            else:
                if len(tags_dict[tag]) >= args.tags:
                    print ("[{}] ({}):".format(tag, len(tags_dict[tag])))
                    if args.names:
                        for bookmark in tags_dict[tag]:
                            print ("    {:<4} - {}".format(bookmark["id"], bookmark["title"]))
                        print()
        exit()

    while (1):
        match_cnt = 0
        change_cnt = 0
        do_all = False

        print ("""
---------------------------------------------------------------------
Options:
  p:  Print the bookmarks tree hierarchy

  s:  Search term entry (currently <{}>)
  t:  List   bookmark search matches
  T:  Delete bookmark search matches
  g:  List   folder names search matches
  G:  Delete folder names search matches

  d:  List   duplicate URLs
  D:  Delete duplicate URLs

  f:  List   duplicate folders
  F:  Delete duplicate folders

  x:  List   tags on folders (only leaf nodes should have tags)
  X:  Delete tags on folders

  y:  List   empty folders (may arise if all children were deleted)
  Y:  Delete empty folders

  w:  Write out the bookmarks data to a file
  q:  Quit/exit (Do a Write first!)
""".format(term))

        select = prompt("Enter option: ", valid="stTgGdDfFxXyYwq")
        if select == 'p':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="printtree")

        elif select == 's':
            term = prompt_long("Search for (empty to clear search term): ").lower()
            if term == "":
                term = NONE_SEARCH
        elif select == 't':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="search")
            print ("\nMatches: {}\n".format(match_cnt))
        elif select == 'T':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="search", commit=True)
            delete_items ()  # Do the queued deletes
            print ("\nMatches: {}  Deletes: {}\n".format(match_cnt, change_cnt))
        elif select == 'g':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="SearchFolders")
            print ("\nMatches: {}\n".format(match_cnt))
        elif select == 'G':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="SearchFolders", commit=True)
            delete_items ()  # Do the queued deletes
            print ("\nMatches: {}  Deletes: {}\n".format(match_cnt, change_cnt))

        elif select == 'd':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupURLs")
            dup_urls()
            url_dict = {}
            print ("\nMatches: {}\n".format(match_cnt))
        elif select == 'D':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupURLs")
            dup_urls(commit=True)
            url_dict = {}
            delete_items ()
            print ("\nMatches: {}  Deletes: {}\n".format(match_cnt, change_cnt))

        elif select == 'f':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupFolders")
            dup_folders()
            folder_dict = {}
            print ("\nMatches: {}\n".format(match_cnt))
        elif select == 'F':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupFolders")
            dup_folders(commit=True)
            folder_dict = {}
            delete_items ()
            print ("\nMatches: {}  Deletes: {}\n".format(match_cnt, change_cnt))

        elif select == 'x':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="FolderTags")
            print ("\nMatches: {}\n".format(match_cnt))
        elif select == 'X':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="FolderTags", commit=True)
            print ("\nMatches: {}  Deletes: {}\n".format(match_cnt, change_cnt))

        elif select == 'y':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="EmptyFolders")
            print ("\nMatches: {}\n".format(match_cnt))
        elif select == 'Y':
            digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="EmptyFolders", commit=True)
            delete_items ()
            print ("\nMatches: {}  Deletes: {}\n".format(match_cnt, change_cnt))

        elif select == 'w':
            ans = prompt_long("Output file name (default <{}>: ".format(args.Infile + OFILESUFFIX))
            if ans == "":
                ans = args.Infile + OFILESUFFIX
            with io.open(ans, "w", encoding='utf8') as ofile:
                json.dump(json_dict, ofile, ensure_ascii=False, indent=2)
        elif select == 'q':
            if prompt("Quit now?  (Remember to write any changes first!) ", valid="yn") == 'y':
                exit()
        else:
            print ("Shouldn't have gotten here!")
            exit()


def digin(parent, parent_id, path, search_term, operation, commit=False, indent=""):
    """Recurse through the json dictionary bookmark tree, with operation options."""
    global match_cnt
    global change_cnt
    global do_all
    global path_dict

    item_index = -1
    ans = 'n'

    for item in parent:
        item_index += 1

        if "children" in item:  # ***** Its a folder *****

            if operation == "printtree":
                print ("{:<4} > {}{}".format(item["id"], indent, item["title"]))

            if operation == "SearchFolders":
                # local_changes = False
                if search_term in item["title"].lower():
                    print ("{:<4} > {}{}".format(item["id"], indent, item["title"]))
                    match_cnt += 1
                    if commit:
                        if do_all:
                            ans = 'y'
                        else:
                            ans = prompt("Confirm delete for this item ('y'es, 'a'll, or 'q'uit, default 'n'o) ", valid="yaqn\r")
                            if ans == 'a':
                                do_all = True
                                ans = 'y'
                        if ans == 'y':
                            collect_items (path + parent_id, parent, item_index)
                            change_cnt += 1
                        if ans == 'q':
                            if prompt("Discard pending deletes ('y'es, default 'n'o)? ", valid="yn\n") == 'y':
                                path_dict = {}
                            return -1

            if operation == "FolderTags":
                if "tags" in item:
                    print ("\n{:4} - {} >>> {}\n  {}".format(
                        item["id"],
                        path[3:],
                        item["title"],
                        item["tags"]))
                    if "url" in item:
                        if item["url"] == None:
                            print ("  url = None !!!")
                        else:
                            print ("  url: <{}>".format(item["url"]))
                    match_cnt += 1
                    if commit:
                        if do_all:
                            ans = 'y'
                        else:
                            ans = prompt("Confirm immediate delete of tags on this folder ('y'es, 'a'll, or 'q'uit, default 'n'o) ", valid="yaqn\r")
                            if ans == 'a':
                                do_all = True
                                ans = 'y'
                        if ans == 'y':
                            del item["tags"]
                            change_cnt += 1
                        if ans == 'q':
                            return -1

            if operation == "EmptyFolders":
                if len(item["children"]) == 0:
                    print ("\n{:4} - {} >>> {}".format(
                        item["id"],
                        path, #[3:],
                        item["title"]))
                    match_cnt += 1
                    if commit:
                        if do_all:
                            ans = 'y'
                        else:
                            ans = prompt("Confirm delete of this empty folder ('y'es, 'a'll, or 'q'uit, default 'n'o) ", valid="yaqn\r")
                            if ans == 'a':
                                do_all = True
                                ans = 'y'
                        if ans == 'y':
                            collect_items (path + parent_id, parent, item_index)
                            change_cnt += 1
                        if ans == 'q':
                            if prompt("Discard pending deletes ('y'es, default 'n'o) ", valid="yn\r") == 'y':
                                path_dict = {}  # TODO
                            return -1

            if operation == "DupFolders":
                log_folder (item["title"], path, item["id"], parent, parent_id, item_index)


            if digin (      # Recurse down to the next level
                    parent = item["children"],
                    parent_id = str(item["id"]),
                    path = path + " > " + item["title"],
                    search_term = search_term,
                    operation = operation,
                    commit = commit,
                    indent = indent + "  ") == -1:
                return -1       # recursion 'q'uit switch


        else:  # ***** Its a Leaf node *****
            if operation == "printtree":
                tags = ""
                if "tags" in item:

                    for tag in item["tags"]:
                        tags += tag + ", "
                print ("{:<4} - {}{}  [{}]".format(item["id"], indent, item["title"], tags[:-2]))

            if operation == "DupURLs":
                log_urls (item["url"], path, item["id"], parent, parent_id, item_index)

            if operation == "search":
                match = False
                if search_term in item["url"].lower() or search_term in item["title"].lower():
                    match = True
                if "tags" in item:
                    if item["tags"] != None:
                        for tag in item["tags"]:
                            if search_term in tag:
                                match = True

                if match:
                    match_cnt += 1
                    print ("\n{:4} - {} >>> {}\n  url:  <{}>".format(
                        item["id"],
                        path[3:],
                        item["title"],
                        item["url"]))
                    if "tags" in item:
                        if item["tags"] == None:
                            print ("  tags = None !!!")
                        else:
                            print ("  tags: <{}>".format(item["tags"]))

                    if commit:
                        if do_all:
                            ans = 'y'
                        else:
                            ans = prompt("Confirm delete for this item ('y'es, 'a'll, or 'q'uit, default 'n'o) ", valid="yaqn\r")
                            if ans == 'a':
                                do_all = True
                                ans = 'y'
                        if ans == 'y':
                            collect_items (path + parent_id, parent, item_index)
                            change_cnt += 1
                        if ans == 'q':
                            if prompt("Discard pending deletes ('y'es, default 'n'o) ", valid="yn\r") == 'y':
                                path_dict = {}
                            return -1

            if operation == "GatherTags":
                if "tags" in item:
                    if item["tags"] != None:
                        for tag in item["tags"]:
                            if tag not in tags_dict:
                                tags_dict[tag] = [{"title":item["title"], "id":item["id"]}]
                            else:
                                tags_dict[tag].append({"title":item["title"], "id":item["id"]})



path_dict = {}
def collect_items (path, parent, index):
    """Collect items for later deletion by delete_items."""
    global path_dict
    # print (path)        #  "/ > [xbs] Toolbar > Chris' > Blogs166"
    # print (parent)      # List of dictionaries of bookmarks [{}, {}, {}]
    # print (index)       # Index within the list - 2
    if path not in path_dict:
        path_dict[path] = {"parent":parent, "index":[int(index)]}
    else:
        # else clause could happen for two identical bookmarks in the same folder, but normally they would have different ID#s
        path_dict[path]["index"].append(int(index))

def delete_items ():
    """Delete queued-up items by collect_items.
	Note:  Items in lists that are to be deleted must be deleted from the highest
	index / item number to the lowest, else the index numbers will be wrong, resulting
	in garbage.  This is the purpose of the collect_items and delete_items functions.
	"""
    global path_dict

    for path in path_dict:
        xxx = path_dict[path]["index"]
        xxx.sort(reverse=True)
        for _index in xxx:
            del path_dict[path]["parent"][_index]
    path_dict = {}



def log_folder (foldername, path, _id, parent, parent_id, item_index):
    """Capture list of all instances of a folder name."""
    path_pid = path + "  [" + parent_id + "]"
    if foldername not in folder_dict:
        folder_dict[foldername] = {"instance": [{"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id}]}
    else:
        folder_dict[foldername]["instance"].append({"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id})

def dup_folders (commit=False):
    """List out, and optionally delete, duplicate folders."""
    global match_cnt
    global change_cnt
    global folder_dict
    global path_dict
    yes_all = False
    for folder in folder_dict:
        if len(folder_dict[folder]["instance"]) > 1:
            print ("-------------------------------------------------\n{}".format(folder))
            for instance in sorted(folder_dict[folder]["instance"], key=lambda k: k['id']):   # Sort id numbers so that lowest is kept for 'a'll mode
                print ("  {:>4} - {}".format(instance["id"], instance["path"]))
                match_cnt += 1
            if commit:
                print ("")
                first_instance = True
                for instance in sorted(folder_dict[folder]["instance"], key=lambda k: k['id']):
                    if first_instance and yes_all:
                        first_instance = False
                        continue        # When yes_all is active, always keep first instance of duplicate folders
                    first_instance = False
                    if not yes_all:
                        print ("  {:>4} - {}".format(instance["id"], instance["path"]))
                        ans = prompt ("Confirm delete for this item ('y'es, 'q'uit, 's'kip to next URL, 'a'll - default 'n'o) ", valid="yqsan\r")
                    if ans == 'a':
                        yes_all = True
                    if ans == 'y' or yes_all:
                        collect_items (instance["path"], instance["parent"], instance["item_index"])
                        change_cnt += 1
                    if ans == 's':
                        break
                    if ans == 'q':
                        if prompt("Discard pending deletes ('y'es, default 'n'o) ", valid="yn\r") == 'y':
                            path_dict = {}
                        return -1



def log_urls (url, path, _id, parent, parent_id, item_index):
    """Capture a list of all instances of each URL."""
    path_pid = path + "  [" + parent_id + "]"
    if url not in url_dict:
        url_dict[url] = {"instance": [{"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id}]}
    else:
        url_dict[url]["instance"].append({"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id})

def dup_urls (commit=False):
    """List out, and optionally delete, bookmarks with the same URL."""
    global match_cnt
    global change_cnt
    global path_dict
    yes_all = False
    for url in url_dict:
        if len(url_dict[url]["instance"]) > 1:
            print ("-------------------------------------------------\n{}".format(url))
            for instance in sorted(url_dict[url]["instance"], key=lambda k: k['id']):   # Sort id numbers so that lowest is kept for 'a'll mode
                print ("  {:>4} - {}".format(instance["id"], instance["path"]))
                match_cnt += 1
            if commit:
                print ("")
                first_instance = True
                for instance in sorted(url_dict[url]["instance"], key=lambda k: k['id']):
                    if first_instance and yes_all:
                        first_instance = False
                        continue        # When yes_all is active, always keep first instance of duplicate bookmarks
                    first_instance = False
                    if not yes_all:
                        print ("  {:>4} - {}".format(instance["id"], instance["path"]))
                        ans = prompt ("Confirm delete for this item ('y'es, 'q'uit, 's'kip to next URL, 'a'll - default 'n'o) ", valid="yqsan\r")
                    if ans == 'a':
                        yes_all = True
                    if ans == 'y' or yes_all:
                        collect_items (instance["path"], instance["parent"], instance["item_index"])
                        change_cnt += 1
                    if ans == 's':
                        break
                    if ans == 'q':
                        if prompt("Discard pending deletes ('y'es, default 'n'o) ", valid="yn\r") == 'y':
                            path_dict = {}
                        return -1



def prompt_long (prompt_text):
    return (input(prompt_text))

def prompt (prompt_text, valid=None):
    print(prompt_text, end='', flush=True)
    select = getch()
    if valid is not None:
        while select not in valid:
            xx = list(valid)
            yy = ", ".join(xx).replace("\r", "enter")
            print("\nInvalid input - expecting one of <{}>: ".format(yy), end='', flush=True)
            select = getch()
    print(select)
    return select

class _Getch:
    """Gets a single character from standard input.  Does not echo to the screen.
    """
    def __init__(self):
        try:
            self.impl = _GetchWindows()
        except ImportError:
            self.impl = _GetchUnix()

    def __call__(self): return self.impl()

class _GetchUnix:
    def __init__(self):
        import tty, sys

    def __call__(self):
        import sys, tty, termios
        fd = sys.stdin.fileno()
        old_settings = termios.tcgetattr(fd)
        try:
            tty.setraw(sys.stdin.fileno())
            ch = sys.stdin.read(1)
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        return ch

class _GetchWindows:
    def __init__(self):
        import msvcrt

    def __call__(self):
        import msvcrt
        # return msvcrt.getch()
        return msvcrt.getch().decode("utf-8")

getch = _Getch()


# def get_item (path):
#     """Lookup leaf node data given hierarchical path info.  NOT USED, BUT POTENTIALLY INTERESTING.
# Example passed-in path list structure:
#     [ "xbrowsersync", "data", "bookmarks", 2, "ATV", 7, "Honda parts", 4 ]
#     A named item translates to "children" in the json_dict hierarchy.
# Corresponds to this path in json_dict:
#     json_dict["xbrowsersync"]["data"]["bookmarks"][2]["children"][7]["children"][4]

# Returns dictionary:
#    {"title" : "<title text>",
#     "url"   : "<url text>",
#     "id"    : id (int)
#     "tags"  : [ "<tags>", "<list"> ],
#     "text_path: "<string concat of titles and list indexes>"
#     }
# """
#     global json_dict

#     JOINTEXT = " > "

#     xxx = json_dict["xbrowsersync"]["data"]["bookmarks"]
#     text_full_path = ""

#     for item in path:
#         print (item)
#         if type(item) is str:       # Its the title of a folder (child) level
#             xxx = xxx["children"]
#         else:                       # Its a list index (integer)
#             text_full_path += JOINTEXT + xxx[item]["title"]
#             xxx = xxx[item]

#     tags = ""
#     if "tags" in xxx:
#         tags = xxx["tags"]

#     return {
#         "title" : xxx["title"],
#         "url"   : xxx["url"],
#         "id"    : xxx["id"],
#         "tags"  : tags,
#         "text_full_path": text_full_path
#         }


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument('Infile',
                        help="json backup file.")
    parser.add_argument('--print', '-p', action='store_true',
                        help="Print bookmark hierarchy (redirect to less or a file).")
    parser.add_argument('--tags', '-t', type=int, default=-1,
                        help="Print tags list filtered by tags with a minimum <n> number of uses.  n=0 prints only single use tags.")
    parser.add_argument('--names', '-n', action='store_true',
                        help="Used with --tags to enable printing the titles/names for each tag.")
    parser.add_argument('-V', '--version',
                        help="Return version number and exit.",
                        action='version',
                        version='%(prog)s ' + __version__)
    args = parser.parse_args()

    if not os.path.exists(args.Infile):
        print ("Can't find the input file <{}>".format(args.Infile))
        exit()

    main()
