#!/usr/bin/env python3
# tasks - Extract, remove, and otherwise manipulate task lists
# Author: Perette Barella
# Copyright 2018 Devious Fish.  All rights reserved.
# vim: ts=4:sw=4:expandtab
# $Id: tasks.py 57 2020-09-27 15:55:25Z perette $

import re, argparse, io, sys

global debug



# Check if the passed task list is fully complete.  I.e., all tasks
# and subtasks are marked completed.
# @param tasks A list of tasks
# @return True if all tasks are fully complete, false otherwise.
def all_tasks_fully_complete (tasks):
    for task in tasks:
        if not task ['complete'] or not all_tasks_fully_complete (task ['subtasks']):
            return False
    return True

# Check if the passed task is fully complete.
# I.e., the task itself, along with all its subtasks, must be complete.
# @param task The task to check
# @return True if the task is fully complete, false otherwise.
def is_task_fully_complete (task):
    return task ['complete'] and all_tasks_fully_complete (task ['subtasks'])

# Check if the passed task list has any fully complete tasks.
# I.e., any on task the list, along with all its subtasks, must be complete.
# @param tasks A list of tasks
# @return True if any task is fully complete, false otherwise.
def any_task_fully_complete (tasks):
    for task in tasks:
        if is_task_fully_complete (task):
            return True
    return False

# Check if the passed task list has any completed items.
# Tasks need not be fully complete.
# @param tasks A list of tasks
# @return True if any item is at least partially complete, false otherwise.
def any_complete_items (tasks):
    for task in tasks:
        if task ['complete'] or any_complete_items (task ['subtasks']):
            return True
    return False

# Select any and all items.
def all_items (tasks):
    return True



# Check if a selector matches any tasks
# @param tasks The list of tasks
# @param selector The selector.
def any_tasks_match (tasks, select):
    for task in tasks:
        if select (task):
            return True
    return False

    
# Dump tasks to some file, text/markdown format
# @param target The destination for the data.
# @param tasks The list of tasks.
# @param select A labmda function choosing what to dump.  Default=all.
# @internal @param level Used on recursive calls for indentation.
# @return Nothing.
def dump_tasks_text (target, tasks, select = all_items, level = 0, *,
                     render_statuses = True):
    assert (isinstance (target, io.IOBase))
    assert (isinstance (tasks, list))
    assert (isinstance (level, int))

    leader = ""
    for i in range (0, level):
        leader = leader + "  "
    for task in tasks:
        if select (task):
            if task ['heading']:
                status = '#'
            elif render_statuses:
                status = "- [x]" if task ['complete'] else "- [ ]"
            else:
                status = '-'
            print ("{leader}{status} {detail}".format (
                            leader=leader,
                            status=status,
                            detail=task ['description']),
                   file=target)
            dump_tasks_text (target, task ['subtasks'], select, level + 1,
                             render_statuses=render_statuses)



# Convert Markdown text to HTML.  Text should be entity-encoded prior to this function.
# @param text The text to be marked up.
# @return The marked up text.
def markdown_to_html (text):
    for markup in markdown_to_html.quote_patterns:
        quote = markup ['regex']
        for pattern in [quote + '(\S.*\S)' + quote, quote + '(\S)' + quote]:
            for start in [ '\A()', '(.*\s)' ]:
                for end in [ '([.,;:]?)\Z', '([.,;:]?\s.*)' ]:
                    while True:
                        match = re.search (start + pattern + end, text)
                        if not match:
                            break
                        text = (match.group (1) + '<' + markup ['tag'] + '>' +
                                match.group (2) + '</' + markup ['tag'] + '>' +
                                match.group (3))
    return text
markdown_to_html.quote_patterns = [
    { 'regex': "`", 'tag': "code"},
    { 'regex': "_", 'tag': "cite"},
    { 'regex': "\*\*", 'tag': "strong"},
    { 'regex': "\*", 'tag': "em"}
]
    
def entity_encode (*text):
    message = "";
    for bit in text:
        message += (bit.replace ('&', '&amp;').
                    replace ('<', '&lt;').
                    replace ('>', '&gt;')) + ' '
    return message.strip()


# Dump tasks to some file, HTML format.
# @param target The destination for the data.
# @param tasks The list of tasks.
# @param select A labmda function choosing what to dump.  Default=all.
# @internal @param level Used on recursive calls for indentation.
# @return Nothing.
def dump_tasks_html (target, tasks, select = all_items, recursing=False):
    assert (isinstance (target, io.IOBase))
    assert (isinstance (tasks, list))

    first = True
    for task in tasks:
        if select (task):
            if (first):
                print ("<ul>" if recursing else "<ul class='todolist'>",
                       file=target)
                first = False
            print ("<li{done}><span>{detail}</span>".format (
                            done=" class='heading'" if task ['heading'] else
                                 " class='completed'" if task ['complete'] else "",
                            detail=markdown_to_html (entity_encode (task ['description']))),
                   file=target)
            dump_tasks_html (target, task ['subtasks'], select, True)
            print ("</li>", file=target)
    if (not first):
        print ("</ul>", file=target)




# Extract tasks from a document.
# @param doc A document in the form of a list of dictionary.  Each entry contains:
# - content - the text of a line
# - retain - A boolean, indicating if the line is to be written.
# @return An extracted list of tasks.
def extract_tasks (doc):
    global debug
    assert (isinstance (doc, list))

    heading_line = re.compile ("\s*(#+)\s*(.*)$")
    task_line = re.compile ("(\s*)\[([Xx ])\]\s*(.*)$")
    list_task_line = re.compile ("(\s*[-*]\s+)\[([Xx ])\]\s*(.*)$")

    tasks = []
    levels = []
    heading = None
    for entry in doc:
        line = entry ['content']
        splits = task_line.match (line)
        if (splits == None):
            splits = list_task_line.match (line)
        if (splits):
            task = {
                'indent': len (splits.group(1)),
                'heading': False,
                'entry': entry,
                'description': splits.group (3),
                'complete': splits.group (2).lower() == 'x',
                'subtasks': []
            }
            # Back out of deeper tasks
            level = len (levels) - 1
            while level >= 0 and levels [level]['indent'] > task ['indent']:
                level -= 1;
            levels = levels [0:level + 1]

            if level == -1:
                # Starting level
                if (debug):
                     print ("Starting new list", task ['description'], file=sys.stderr)
                if heading != None:
                    tasks.append (heading)
                    levels.append (heading)
                    heading ['subtasks'].append (task)
                    heading = None
                else:
                    tasks.append (task)
                levels.append (task)
            elif levels [level]['indent'] == task ['indent']:
                # Matching existing level
                if (debug):
                    print ("Adding to existing level", task ['description'], file=sys.stderr)
                if level == 0:
                    tasks.append (task)
                else:
                    levels [level - 1]['subtasks'].append (task)
                levels [level] = task
            else:
                # Starting a new level
                if (debug):
                    print ("Starting from existing level", level, task ['description'],
                           file=sys.stderr)
                levels.append (task)
                levels [level]['subtasks'].append (task)
        else:
            splits = heading_line.match (line)
            if splits:
                if (debug):
                    print ("Heading pending:", splits.group (2), file=sys.stderr)
                heading = {
                    'indent': -1,
                    'heading': True,
                    'entry': entry,
                    'description': splits.group (2),
                    'complete': True,
                    'subtasks': [],
                }
                levels = []
            elif (debug):
                print ("Not a task: ", entry ['content'], file=sys.stderr)
    return tasks
    


# Read files and extract tasks.
# @param files The list of files to read.
# @return Tuple of (tasks, documents):
# - tasks - contains a list of all tasks.
# - documents - a dictionary keyed by filename containing the read documents.
def read_tasks (files):
    assert (isinstance (files, list))

    tasks = []
    documents = { }
    for doc in files:
        with open (doc, encoding='utf-8') as source:
            document = []
            for line in source.readlines():
                document.append ({'retain': True, 'content': line})
            doc_tasks = extract_tasks (document)
            tasks.extend (doc_tasks)
            documents [doc] = document

    return (tasks, documents)


# Write tasks to files.
# @param tasks The tasks to write.
# @param target_name Where to write files, or stdout if 'None'.
# @param output_format The file format to write.
def write_tasks (tasks, target_name = None, output_format = 'none', *,
                 completed_only = False, leading_blank = False,
                 leading_message = None,
                 render_statuses = True):
    assert (isinstance (tasks, list))
    assert (target_name == None or isinstance (target_name, str))
    assert (isinstance (output_format, str))
    assert (isinstance (completed_only, bool))
    assert (isinstance (leading_blank, bool))
    assert (leading_message == None or isinstance (leading_message, str))

    if (output_format == 'none'):
        return

    if target_name:
        destination = open (args.output, "w", encoding='utf-8')
    else:
        destination = sys.stdout

    if completed_only:
        selector = lambda item: (any_task_fully_complete (item ['subtasks']) if
                        item ['heading'] else is_task_fully_complete (item))
    else:
        selector = all_items

    if any_tasks_match (tasks, selector):
        if leading_blank:
            print (file=destination)
        if leading_message != None:
            print (leading_message, file=destination)

    if output_format == 'markdown':
        dump_tasks_text (destination, tasks, selector,
                         render_statuses=render_statuses)
    elif output_format == 'html':
        dump_tasks_html (destination, tasks, selector)
    else:
        assert ("Invalid output format type" and False)

    if target_name:
        destination.close()


# Mark out completed tasks from the original input files
# @param tasks The list of tasks.
def strip_complete_tasks (tasks):
    global debug
    def strip_recurse (tk):
        if (debug):
            print ("Removing:", tk ['description'], file=sys.stderr)
        tk ['entry']['retain'] = False
        for sub in tk ['subtasks']:
            strip_recurse (sub)

    for task in tasks:
        if task ['heading']:
            strip_complete_tasks (task ['subtasks'])
        elif is_task_fully_complete (task):
            strip_recurse (task)


# Write modified documents
# @param documents The source documents dictionary.
def rewrite_stripped_files (documents):
    def document_has_changed (document):
        for line in document:
            if line ['retain'] == False:
                return True
        return False

    for filename, document in documents.items():
        if document_has_changed (document):
            with open (filename, "w", encoding='utf-8') as target:
                for line in document:
                    if line ['retain']:
                        target.write (line ['content'])
            
        


# Fix the goddamn argument parser to respect newlines in strings.  Dumbass thing.
class FixedArgumentFormatter(argparse.HelpFormatter):
    def _split_lines(self, text, width):
        results = []
        for paragraph in text.splitlines ():
            results += argparse.HelpFormatter._split_lines(self, paragraph, width)
        return results

    def _format_text (self, text):
        if text == None:
            return argparse.HelpFormatter.add_text (self, text)
        results = ''
        for paragraph in text.splitlines ():
            results += argparse.HelpFormatter._format_text (self, paragraph)
            results = results [:-1]
        return results + '\n'



if __name__ == "__main__":
    global debug

    parser = argparse.ArgumentParser (formatter_class = FixedArgumentFormatter,
        description="%(prog)s - Extract and manage Markdown-formatted task lists.",
        epilog="Copyright 2018 Devious Fish.\nAuthor: Perette Barella <perette@deviousfish.com>")

    parser.add_argument ("-o", "--output", action="store", type=str, dest="output",
                         help="Output file for extracted tasks")
    parser.add_argument ("-f", "--format", action="store", dest="format",
                         choices = ['markdown', 'html', 'none'], default='none',
                         help="Output format")
    parser.add_argument ("-c", "--completed", action="store_true", dest="completed",
                         help="Extract completed tasks only")
    parser.add_argument ("-b", "--blank", action="store_true", dest="blank",
                         help="Include a blank line before output")
    parser.add_argument ("-m", "--message", action="store", dest="message",
                         help="Message to include before output")
    parser.add_argument ("-r", "--remove-status", action="store_true", dest="remove",
                         help="Remove status markers from output")
    parser.add_argument ("-s", "--strip", action="store_true", dest="strip",
                         help="Strip fully completed tasks from input files")

    parser.add_argument ("-Z", "--debug", action="store_true", dest="debug",
                         help=argparse.SUPPRESS)

    parser.add_argument ("source", nargs='+', action="store",
                         help="Documents to process")

    args = parser.parse_args ()
    debug = args.debug

    if (debug):
        print (args, file=sys.stderr)

    (tasks, documents) = read_tasks (args.source)

    write_tasks (tasks, args.output, args.format,
                 completed_only = args.completed,
                 leading_blank = args.blank,
                 leading_message = args.message,
                 render_statuses = not args.remove)

    if args.strip:
        strip_complete_tasks (tasks)
        rewrite_stripped_files (documents)

