#! /usr/bin/env python3
# Script to turn JSON schema into markdown documentation and replace in-place.
# Released by Rusty Russell under CC0:
# https://creativecommons.org/publicdomain/zero/1.0/
from argparse import ArgumentParser
import json
import re

# To maintain the sequence of the before return value (body) and after return value (footer) sections in the markdown file
BODY_KEY_SEQUENCE = ['reliability', 'usage', 'restriction_format', 'permitted_sqlite3_functions', 'treatment_of_types', 'tables', 'example_usage', 'example_json_request', 'notes', 'notifications', 'sharing_runes', 'riskfactor_effect_on_routing', 'recommended_riskfactor_values', 'optimality', 'randomization']
FOOTER_KEY_SEQUENCE = ['example_json_response', 'errors', 'example_json_notifications', 'trivia', 'author', 'see_also', 'resources']


def output_title(title, underline='-', num_leading_newlines=1, num_trailing_newlines=2):
    """Add a title to the output"""
    print('\n' * num_leading_newlines + title, end='\n')
    print(underline * len(title) + '\n' * num_trailing_newlines, end='')
    global current_line_width
    current_line_width = 0


def esc_underscores(s):
    """Backslash-escape underscores outside of backtick-enclosed spans"""
    return ''.join(['\\_' if x == '_' else x for x in re.findall(r'[^`_\\]+|`(?:[^`\\]|\\.)*`|\\.|_', s)])


def json_value(obj):
    """Format obj in the JSON style for a value"""
    if type(obj) is bool:
        if obj:
            return '*true*'
        return '*false*'
    if type(obj) is str:
        return '"' + esc_underscores(obj) + '"'
    if obj is None:
        return '*null*'
    assert False


def outputs(lines, separator=''):
    """Add these lines to the final output"""
    print(esc_underscores(separator.join(lines)), end='')


def output(line):
    """Add this line to the final output"""
    print(line, end='')


def search_key_in_conditional_array(request, param):
    """search param in all conditional subarrays/objects and return the condition and found array/obj"""
    one_of_many_array = request.get('oneOfMany', [])
    paired_with_array = request.get('pairedWith', [])

    # Check if the same parameter is in both 'pairedWith' and 'oneOfMany' and throw an error if found
    common_key = next((element_one for subarray_one in one_of_many_array for element_one in subarray_one for subarray_paired in paired_with_array if element_one in subarray_paired), '')
    assert common_key == '', f'The same parameter "{common_key}" cannot be in both "pairedWith" and "oneOfMany"'

    # Search for the parameter in 'oneOfMany' array
    for sub_array_one in one_of_many_array:
        if param in sub_array_one:
            return 'oneOfMany', sub_array_one

    # Search for the parameter in 'pairedWith' array
    for sub_array_paired in paired_with_array:
        if param in sub_array_paired:
            return 'pairedWith', sub_array_paired

    # If param doesn't exist in any of the conditional arrays, return empty condition and None
    return '', None


def output_conditional_params(conditional_sub_array, condition):
    """Output request parameters with appropriate separator based on the separator"""
    # If the request has 'oneOfMany', then print them in one param section with OR (|) sign.
    # 'oneOfMany' example `plugin`: [*plugin|directory*]
    # If the request has 'pairedWith', then print them in one param section separated with space.
    # 'pairedWith' example `delpay`: [*partid* *groupid*]
    # If the request has 'dependentUpon', then print them in one param section separated with space.
    # 'dependentUpon' example `listforwards`: [*index* [*start*] [*limit*]]
    separator = {'oneOfMany': '|', 'pairedWith': '* *', 'dependentUpon': '*] [*'}.get(condition, '')
    # Join all keys with the separator
    keysfoundstr = format(esc_underscores(separator.join(conditional_sub_array)))
    # Print the merged keys
    output('{}{}'.format(fmt_paramname(keysfoundstr, True, False), '' if condition == 'dependentUpon' else ' '))


def output_type(properties, is_optional):
    """Add types for request and reponse parameters"""
    typename = 'one of' if 'oneOf' in properties else esc_underscores(properties['type'])
    if typename == 'array':
        if 'items' in properties and 'type' in properties['items']:
            typename += ' of {}s'.format(esc_underscores(properties['items']['type']))
    if is_optional:
        typename += ', optional'
    output(' ({})'.format(esc_underscores(typename)))


def output_range(properties):
    if 'maximum' and 'minimum' in properties:
        output(' ({} to {} inclusive)'.format(properties['minimum'], properties['maximum']))
    elif 'maximum' in properties:
        output(' (max {})'.format(properties['maximum']))
    elif 'minimum' in properties:
        output(' (min {})'.format(properties['minimum']))

    if 'maxLength' and 'minLength' in properties:
        if properties['minLength'] == properties['maxLength']:
            output(' (always {} characters)'.format(properties['minLength']))
        else:
            output(' ({} to {} characters)'.format(properties['minLength'], properties['maxLength']))
    elif 'maxLength' in properties:
        output(' (up to {} characters)'.format(properties['maxLength']))
    elif 'minLength' in properties:
        output(' (at least {} characters)'.format(properties['minLength']))

    if 'enum' in properties:
        if len(properties['enum']) == 1:
            output(" (always {})".format(json_value(properties['enum'][0])))
        else:
            output(' (one of {})'.format(', '.join([json_value(p) for p in properties['enum']])))


def fmt_propname(propname):
    """Pretty-print format a property name"""
    return '**{}**'.format(esc_underscores(propname))


def fmt_paramname(paramname, is_optional=True, trailing_space=True):
    """Pretty-print format a parameter name"""
    return '[*{}*]{}'.format(esc_underscores(paramname), ' ' if trailing_space else '') if is_optional else '*{}*{}'.format(esc_underscores(paramname), ' ' if trailing_space else '')


def deprecated_to_deleted(vername):
    """We promise a 6 month minumum deprecation period, and versions are every 3 months"""
    assert vername.startswith('v')
    base = [int(s) for s in vername[1:].split('.')[0:2]]
    if base == [0, 12]:
        base = [22, 8]
    base[1] += 9
    if base[1] > 12:
        base[0] += 1
        base[1] -= 12
    # Christian points out versions should sort well lexographically,
    # so we zero-pad single-digits.
    return 'v{}.{:0>2}'.format(base[0], base[1])


def output_member(propname, properties, is_optional, indent, print_type=True, prefix=None):
    """Generate description line(s) for this member"""
    # Skip hidden properties
    if 'hidden' in properties and properties['hidden']:
        return

    if prefix is None:
        prefix = '- ' + fmt_propname(propname) if propname is not None else '-'
    output(indent + prefix)

    # We make them explicitly note if they don't want a type!
    is_untyped = 'untyped' in properties

    if not is_untyped and print_type:
        output_type(properties, is_optional)

    output_range(properties)

    if 'description' in properties:
        for i in range(0, len(properties['description'])):
            output('{} {}{}'.format(':' if i == 0 else '', esc_underscores(properties['description'][i]), '' if i + 1 == len(properties['description']) else '\n'))

    if 'default' in properties:
        output(' The default is {}.'.format(esc_underscores(properties['default']) if isinstance(properties['default'], str) else properties['default']))

    if 'deprecated' in properties:
        output(' **deprecated in {}, removed after {}**'.format(properties['deprecated'][0], properties['deprecated'][1] if len(properties['deprecated']) > 1 else deprecated_to_deleted(properties['deprecated'][0])))

    if 'added' in properties:
        output(' *(added {})*'.format(properties['added']))

    if 'oneOf' in properties and isinstance(properties['oneOf'], list):
        output(':\n')
        output_members(properties, indent + '  ')
    elif not is_untyped and properties['type'] == 'object':
        output(':\n')
        output_members(properties, indent + '  ')
    elif not is_untyped and properties['type'] == 'array':
        output(':\n')
        output_array(properties['items'], indent + '  ')
    else:
        output('\n')


def output_array(items, indent):
    """We've already said it's an array of {type}"""
    if 'oneOf' in items and isinstance(items['oneOf'], list):
        output_members(items, indent + '  ')
    elif items['type'] == 'object':
        output_members(items, indent)
    elif items['type'] == 'array':
        output(indent + '-')
        output_type(items, False)
        output(': {}\n'.format(esc_underscores('\n'.join(items['description']))) if 'description' in items and len(items['description']) > 0 else '\n')
        if 'items' in items:
            output_array(items['items'], indent + '  ')
    else:
        if 'type' in items:
            output_member(None, items, True, indent)


def has_members(sub):
    """Does this sub have any properties to print?"""
    for p in list(sub['properties'].keys()):
        if len(sub['properties'][p]) == 0:
            continue
        if sub['properties'][p].get('deprecated') is True:
            continue
        return True
    return False


def output_members(sub, indent=''):
    """Generate lines for these properties"""
    warnings = []
    if 'properties' in sub:
        for p in list(sub['properties'].keys()):
            if len(sub['properties'][p]) == 0 or sub['properties'][p].get('deprecated') is True:
                del sub['properties'][p]
            elif p.startswith('warning'):
                warnings.append(p)

        # First list always-present properties
        for p in sub['properties']:
            if p.startswith('warning'):
                continue
            if 'required' in sub and p in sub['required']:
                output_member(p, sub['properties'][p], False, indent)

        for p in sub['properties']:
            if p.startswith('warning'):
                continue
            if 'required' not in sub or p not in sub['required']:
                output_member(p, sub['properties'][p], True, indent)

    if warnings != []:
        output(indent + '- the following warnings are possible:\n')
        for w in warnings:
            output_member(w, sub['properties'][w], False, indent + '  ', print_type=False)

    if 'oneOf' in sub:
        for oneOfItem in sub['oneOf']:
            if 'type' in oneOfItem and oneOfItem['type'] == 'array':
                output_array(oneOfItem, indent)
            else:
                output_member(None, oneOfItem, False, indent, False if 'enum' in oneOfItem else True)

    # If we have multiple ifs, we have to wrap them in allOf.
    if 'allOf' in sub:
        ifclauses = sub['allOf']
    elif 'if' in sub:
        ifclauses = [sub]
    else:
        ifclauses = []

    # We partially handle if, assuming it depends on particular values of prior properties.
    for ifclause in ifclauses:
        conditions = []

        # 'required' are fields that simply must be present
        for r in ifclause['if'].get('required', []):
            conditions.append(fmt_propname(r) + ' is present')

        # 'properties' are enums of field values
        for tag, vals in ifclause['if'].get('properties', {}).items():
            # Don't have a description field here, it's not used.
            assert 'description' not in vals
            whichvalues = vals['enum']

            cond = fmt_propname(tag) + ' is'
            if len(whichvalues) == 1:
                cond += ' {}'.format(json_value(whichvalues[0]))
            else:
                cond += ' {} or {}'.format(', '.join([json_value(v) for v in whichvalues[:-1]]),
                                           json_value(whichvalues[-1]))
            conditions.append(cond)

        sentence = indent + 'If ' + ', and '.join(conditions) + ':\n'

        if has_members(ifclause['then']):
            # Prefix with blank line.
            outputs(['\n', sentence])
            output_members(ifclause['then'], indent + '  ')


def generate_header(schema):
    """Generate lines for rpc title and synopsis with request parameters"""
    output_title(esc_underscores(''.join(['lightning-', schema['rpc'], ' -- ', schema['title']])), '=', 0, 1)
    output_title('SYNOPSIS')
    # Add command level warning if exists
    if 'warning' in schema:
        output('**(WARNING: {})**\n\n'.format(esc_underscores(schema['warning'])))
    # generate the rpc command details with request parameters
    request = schema['request']
    properties = request['properties']
    toplevels = list(request['properties'].keys())
    output('{} '.format(fmt_propname(schema['rpc'])))
    i = 0
    while i < len(toplevels):
        # Skip hidden properties
        if 'hidden' in properties[toplevels[i]] and properties[toplevels[i]]['hidden']:
            i += 1
            continue
        # Search for the parameter in 'dependentUpon' array
        dependent_upon_obj = request['dependentUpon'] if 'dependentUpon' in request else []
        if toplevels[i] in dependent_upon_obj:
            # Output parameters with appropriate separator
            output('{}*{}* '.format('' if 'required' in request and toplevels[i] in request['required'] else '[', esc_underscores(toplevels[i])))
            output_conditional_params(dependent_upon_obj[toplevels[i]], 'dependentUpon')
            toplevels = [key for key in toplevels if key not in dependent_upon_obj[toplevels[i]]]
            output('{}'.format('' if 'required' in request and toplevels[i] in request['required'] else ']'))
        else:
            # Search for the parameter in any conditional sub-arrays (oneOfMany, pairedWith)
            condition, foundinsubarray = search_key_in_conditional_array(request, toplevels[i])
            # If param found in the conditional sub-array
            if condition != '' and foundinsubarray is not None:
                # Output parameters with appropriate separator
                output_conditional_params(foundinsubarray, condition)
                # Remove found keys from toplevels array
                toplevels = [key for key in toplevels if key not in foundinsubarray]
                # Reset the cursor to the previous index
                i = i - 1
            else:
                # Print the key as it is if it doesn't exist in conditional array
                output('{}'.format(fmt_paramname(toplevels[i], False if 'required' in request and toplevels[i] in request['required'] else True)))
        i += 1
    # lightning-plugin.json is an exception where all parameters cannot be printed deu to their dependency on different subcommands
    # So, add ... at the end for lightning-plugin schema
    if schema['rpc'] == 'plugin':
        output('...')
    output('\n')


def generate_description(schema):
    """Generate rpc description with request parameter descriptions"""
    request = schema['request']
    output_title('DESCRIPTION')
    # Add deprecated and removal information for the command
    if 'deprecated' in schema:
        output('Command **deprecated in {}, removed after {}**.\n\n'.format(schema['deprecated'][0], schema['deprecated'][1] if len(schema['deprecated']) > 1 else deprecated_to_deleted(schema['deprecated'][0])))
    # Version when the command was added
    if 'added' in schema:
        output('Command *added* in {}.\n\n'.format(schema['added']))
    # Command's detailed description
    outputs(schema['description'], '\n')
    # Request parameter's detailed description
    output('{}'.format('\n\n' if len(request['properties']) > 0 else '\n'))
    output_members(request)


def generate_return_value(schema):
    """This is not general, but works for us"""
    output_title('RETURN VALUE')

    response = schema['response']

    if 'pre_return_value_notes' in response:
        outputs(response['pre_return_value_notes'], '\n')
        output('\n')

    toplevels = []
    warnings = []
    props = response['properties']

    # We handle warnings on top-level objects with a separate section,
    # so collect them now and remove them
    for toplevel in list(props.keys()):
        if toplevel.startswith('warning'):
            warnings.append((toplevel, props[toplevel]['description']))
            del props[toplevel]
        else:
            toplevels.append(toplevel)

    # No properties -> empty object.
    if toplevels == []:
        # Use pre/post_return_value_notes with empty properties when dynamic generation of the return value section is not required.
        # But to add a custom return value section instead. Example: `commando` commands.
        if "pre_return_value_notes" not in response and "post_return_value_notes" not in response:
            output('On success, an empty object is returned.\n')
        sub = schema
    elif len(toplevels) == 1 and props[toplevels[0]]['type'] == 'object':
        output('On success, an object containing {} is returned. It is an object containing:\n\n'.format(fmt_propname(toplevels[0])))
        # Don't have a description field here, it's not used.
        assert 'description' not in toplevels[0]
        sub = props[toplevels[0]]
    elif len(toplevels) == 1 and props[toplevels[0]]['type'] == 'array' and props[toplevels[0]]['items']['type'] == 'object':
        output('On success, an object containing {} is returned. It is an array of objects, where each object contains:\n\n'.format(fmt_propname(toplevels[0])))
        # Don't have a description field here, it's not used.
        assert 'description' not in toplevels[0]
        sub = props[toplevels[0]]['items']
    else:
        output('On success, an object is returned, containing:\n\n')
        sub = response

    output_members(sub)

    if warnings:
        output('\nThe following warnings may also be returned:\n\n')
        for w, desc in warnings:
            output('- {}: {}\n'.format(fmt_propname(w), ''.join(desc)))

    if 'post_return_value_notes' in response:
        if len(props.keys()) > 0:
            output('\n')
        outputs(response['post_return_value_notes'], '\n')
        output('\n')


def generate_body(schema):
    """Output sections which should be printed after description and before return value"""
    # Insert extra line between description and next section with this flag
    first_matching_key = True
    # Only add a newline if at least there is one body key found
    body_key_found = False
    for key in BODY_KEY_SEQUENCE:
        if key not in schema:
            continue
        body_key_found = True
        output_title(key.replace('_', ' ').upper(), '-', 1 if first_matching_key else 2)
        first_matching_key = False
        if key == 'example_json_request' and len(schema[key]) > 0:
            output('```json\n')
            for example in schema.get(key, []):
                output(json.dumps(example, indent=2).strip() + '\n')
            output('```')
        else:
            outputs(schema[key], '\n')
    if body_key_found:
        output('\n')


def generate_footer(schema):
    """Output sections which should be printed after return value"""
    first_matching_key = True
    for key in FOOTER_KEY_SEQUENCE:
        if key not in schema:
            continue
        output_title(key.replace('_', ' ').upper(), '-', 1 if first_matching_key else 2)
        first_matching_key = False
        if key == 'see_also':
            # Wrap see_also list with comma separated values
            output(esc_underscores(', '.join(schema[key])))
        elif key.startswith('example_json_') and len(schema[key]) > 0:
            # For printing example_json_response and example_json_notifications into code block
            for i, example in enumerate(schema.get(key, [])):
                # For printing string elements from example json response; example: `createonion`
                if isinstance(example, str):
                    output(example)
                    if i + 1 < len(schema[key]):
                        output('\n')
                else:
                    if i == 0:
                        output('```json\n')
                    output(json.dumps(example, indent=2).strip() + '\n')
                    if i + 1 == len(schema[key]):
                        output('```')
        else:
            outputs(schema[key], '\n')
    output('\n')


def main(schemafile, markdownfile):
    with open(schemafile, 'r') as f:
        schema = json.load(f)
    # Outputs rpc title and synopsis with request parameters
    generate_header(schema)
    # Outputs command description with request parameter descriptions
    generate_description(schema)
    # Outputs other remaining sections before return value section
    generate_body(schema)
    # Outputs command response with response parameter descriptions
    generate_return_value(schema)
    # Outputs other remaining sections after return value section
    generate_footer(schema)

    if markdownfile is None:
        return


if __name__ == '__main__':
    parser = ArgumentParser()
    parser.add_argument('schemafile', help='The schema file to use')
    parser.add_argument('--markdownfile', help='The markdown file to read')
    parsed_args = parser.parse_args()
    main(parsed_args.schemafile, parsed_args.markdownfile)