#!/usr/bin/env python3
# vim: set expandtab tabstop=4 shiftwidth=4:

# Copyright (c) 2020-2022 CJ Kucera (cj@apocalyptech.com)
# 
# This software is provided 'as-is', without any express or implied warranty.
# In no event will the authors be held liable for any damages arising from
# the use of this software.
# 
# Permission is granted to anyone to use this software for any purpose,
# including commercial applications, and to alter it and redistribute it
# freely, subject to the following restrictions:
# 
# 1. The origin of this software must not be misrepresented; you must not
#    claim that you wrote the original software. If you use this software in a
#    product, an acknowledgment in the product documentation would be
#    appreciated but is not required.
# 
# 2. Altered source versions must be plainly marked as such, and must not be
#    misrepresented as being the original software.
# 
# 3. This notice may not be removed or altered from any source distribution.

import os
import sys
import ttwlsave
import argparse
from . import cli_common
from . import plot_missions
from ttwlsave import InvSlot, SDU, ChaosLevel, HeroStats, Backstory
from ttwlsave.ttwlsave import TTWLSave
from ttwlsave.ttwlprofile import TTWLProfile

def main():

    # Set up args
    parser = argparse.ArgumentParser(
            description='Wonderlands CLI Savegame Editor v{} (PC Only)'.format(ttwlsave.__version__),
            formatter_class=argparse.ArgumentDefaultsHelpFormatter,
            epilog="""
                The default output type of "savegame" will output theoretically-valid
                savegames which can be loaded into WL.  The output type "protobuf"
                will save out the extracted, decrypted protobufs.  The output
                type "json" will output a JSON-encoded version of the protobufs
                in question.  The output type "items" will output a text
                file containing base64-encoded representations of the user's
                inventory.  These can be read back in using the -i/--import-items
                option.
            """
            )

    parser.add_argument('-V', '--version',
            action='version',
            version='WL CLI SaveEdit v{}'.format(ttwlsave.__version__),
            )

    parser.add_argument('-o', '--output',
            choices=['savegame', 'protobuf', 'json', 'items'],
            default='savegame',
            help='Output file format',
            )

    parser.add_argument('--csv',
            action='store_true',
            help='When importing or exporting items, use CSV files',
            )

    parser.add_argument('-f', '--force',
            action='store_true',
            help='Force output file to overwrite',
            )

    parser.add_argument('-q', '--quiet',
            action='store_true',
            help='Supress all non-essential output')

    # Actual changes the user can request
    parser.add_argument('--name',
            type=str,
            help='Set the name of the character',
            )

    parser.add_argument('--save-game-id',
            type=int,
            help='Set the save game slot ID (possibly not actually ever needed)',
            )
    # AH: By default if you don't change the GUID it doesn't work in TTWL
    parser.add_argument('--dont-randomize-guid',
            dest='randomize_guid',
            action='store_false',
            default=True,
            help='DON\'T Randomize the savegame GUID',
            )

    levelgroup = parser.add_mutually_exclusive_group()

    levelgroup.add_argument('--level',
            type=int,
            help='Set the character to this level (from 1 to {})'.format(ttwlsave.max_level),
            )

    levelgroup.add_argument('--level-max',
            action='store_true',
            help='Set the character to max level ({})'.format(ttwlsave.max_level),
            )

    parser.add_argument('--xp-max',
            action='store_true',
            help="""When using --level, instead of assigning the minimum amount of XP for
                the level, assign the maximum (so that 1 more XP will level to the next).
                Has no effect when setting a char to max level.""",
            )

    itemlevelgroup = parser.add_mutually_exclusive_group()

    itemlevelgroup.add_argument('--items-to-char',
            action='store_true',
            help='Set all inventory items to the level of the character')

    itemlevelgroup.add_argument('--item-levels',
            type=int,
            help='Set all inventory items to the specified level')

    chaos_level_group=parser.add_mutually_exclusive_group()

    for level in ChaosLevel:
        chaos_level_group.add_argument('--items-{}'.format(level.label.lower()),
                dest='items_chaos_level',
                action='store_const',
                const=level,
                help='Set all inventory item chaos levels to {}'.format(level.label),
                )

    parser.add_argument('--clear-rerolls',
            action='store_true',
            help='Clears the reroll counter for all items in inventory',
            )

    parser.add_argument('--clear-lucky-dice',
            action='store_true',
            help='Remove all Lucky Dice discoveries',
            )

    parser.add_argument('--backstory',
            choices=[b.name.lower() for b in Backstory],
            help='Set backstory',
            )

    hero_group = parser.add_mutually_exclusive_group()

    hero_group.add_argument('--hero-stats',
            type=int,
            help='Sets all raw Hero Stats to the specified value (from 10-30)',
            )

    hero_group.add_argument('--hero-stats-max',
            action='store_true',
            help='Sets all raw Hero Stats to their maximum values',
            )

    for stat in HeroStats:
        arg_short = stat.label.lower()[:3]
        parser.add_argument(f'--{arg_short}',
                type=int,
                metavar=stat.label.upper(),
                dest='hero_stats_individual',
                action=cli_common.DictValueAction,
                key=stat,
                help=f'Sets the value of the {stat.label} Hero Stat (from 10-30)',
                )

    parser.add_argument('--chaos',
            type=int,
            help='Set the Chaos Level',
            )

    parser.add_argument('--money',
            type=int,
            help='Set money value',
            )

    parser.add_argument('--moon-orbs',
            type=int,
            help='Set Moon Orbs value',
            )

    parser.add_argument('--souls',
            type=int,
            help='Set Lost Souls value',
            )


    unlock_choices = [
            'ammo', 'backpack',
            'equipslots',
            'feat', 'multiclass',
            'chaos',
            ]
    parser.add_argument('--unlock',
            action=cli_common.SetAction,
            choices=unlock_choices + ['all'],
            default={},
            help='Game features to unlock',
            )

    parser.add_argument('--fake-tvhm',
            action='store_true',
            help="""Resets all missions and game state.  Preserves XP, skills,
                inventory, unlocked Chaos Mode, etc.""",
            )

    parser.add_argument('--tvhm-reset-overworld',
            action='store_true',
            help="""When using --fake-tvhm, reset all Shrine and Bottlecap
                Shortcut statuses, so they must be unlocked/finished again.""",
            )

    parser.add_argument('-w', '--wipe-inventory',
            action='store_true',
            help="""Wipes all items from inventory.  Will be processed before
                --import-items, so imported items will remain in inventory.""",
            )

    parser.add_argument('-i', '--import-items',
            type=str,
            help='Import items from file',
            )

    parser.add_argument('--delete-mission',
            type=str,
            metavar='MISSIONPATH',
            action='append',
            help="""Deletes all stored info about the specified mission.
                Will only work on sidemissions.  Use ttwl-save-info's
                --mission-paths to see the correct mission path to use
                here.  This option can be specified more than once."""
            )

    parser.add_argument('--randomize-customizations',
            type=str,
            metavar='PROFILE',
            help="""Randomize customziations based on the customizations
                unlocked in the specified profile file.""",
            )

    parser.add_argument('--overdrive',
            action='store_true',
            help="""When randomizing customizations, use the "overdrive"
                slider settings, which allow for wilder extremes.""",
            )

    # Positional args
    parser.add_argument('input_filename',
            help='Input filename',
            )

    parser.add_argument('output_filename',
            help='Output filename',
            )

    # Parse args
    args = parser.parse_args()
    if args.level is not None:
        if args.level < 1 or args.level > ttwlsave.max_supported_level:
            raise argparse.ArgumentTypeError('Valid level range is 1 through {} (currently known in-game max of {})'.format(
                ttwlsave.max_supported_level,
                ttwlsave.max_level,
                ))
        if args.level > ttwlsave.max_level:
            print('WARNING: Setting character level to {}, when {} is the currently-known max'.format(
                args.level,
                ttwlsave.max_level,
                ))

    # If unlock is a dict, none was specified; turn it into a set regardless.
    # (I don't like how `set()` shows up in the default reporting, if I set
    # it right at the argparse level).
    if args.unlock == {}:
        args.unlock = set()

    # Expand any of our "all" unlock actions
    if 'all' in args.unlock:
        args.unlock = set(unlock_choices)

    # Set max level arg
    if args.level_max:
        args.level = ttwlsave.max_level

    # Check item level.  The max storeable in the serial number is 127, but the
    # effective limit in-game is 100, thanks to MaxGameStage attributes.  We
    # could use `ttwlsave.max_level` here, too, of course, but in the event that
    # I don't get this updated in a timely fashion, having it higher would let
    # this util potentially continue to be able to level up gear.
    if args.item_levels:
        if args.item_levels < 1 or args.item_levels > 100:
            raise argparse.ArgumentTypeError('Valid item level range is 1 through 100')
        if args.item_levels > ttwlsave.max_level:
            print('WARNING: Setting item levels to {}, when {} is the currently-known max'.format(
                args.item_levels,
                ttwlsave.max_level,
                ))

    # Check Chaos level
    if args.chaos is not None:
        if args.chaos < 0 or args.chaos > ttwlsave.max_chaos_level:
            raise argparse.ArgumentTypeError(f'Valid Chaos Level range is 0 through {ttwlsave.max_chaos_level}')
        args.unlock.add('set_chaos')

    # Check Hero Stats level
    if args.hero_stats is not None:
        if args.hero_stats < 10 or args.hero_stats > 30:
            raise argparse.ArgumentTypeError('Valid Hero Stat range is 10 through 30')
    if args.hero_stats_max:
        args.hero_stats = 30
    if args.hero_stats_individual:
        for stat, value in args.hero_stats_individual.items():
            if value < 10 or value > 30:
                raise argparse.ArgumentTypeError(f'Valid {stat.label} Stat range is 10 through 30')

    # Check to make sure that any deleted missions are not plot missions
    if args.delete_mission is not None:
        for mission in args.delete_mission:
            if mission.lower() in plot_missions:
                raise argparse.ArgumentTypeError('Plot mission cannot be deleted: {}'.format(mission))

    # Check for overwrite warnings
    if os.path.exists(args.output_filename) and not args.force:
        if args.output_filename == args.input_filename:
            confirm_msg = 'Really overwrite {} with specified changes (no backup will be made)'.format(args.output_filename)
        else:
            confirm_msg = '{} already exists.  Overwrite'.format(args.output_filename)
        sys.stdout.write('WARNING: {} [y/N]? '.format(confirm_msg))
        sys.stdout.flush()
        response = sys.stdin.readline().strip().lower()
        if len(response) == 0 or response[0] != 'y':
            print('Aborting!')
            sys.exit(1)
        print('')

    # Now load the savegame
    if not args.quiet:
        print('Loading {}'.format(args.input_filename))
    save = TTWLSave(args.input_filename)
    if not args.quiet:
        print('')

    # Check to see if we have any changes to make
    have_changes = any([
        args.name,
        args.save_game_id is not None,
        args.randomize_guid,
        args.level is not None,
        args.chaos is not None,
        args.hero_stats is not None,
        # hero_stats_max sets hero_stats, so don't check for it here
        args.hero_stats_individual,
        args.money is not None,
        args.moon_orbs is not None,
        args.souls is not None,
        len(args.unlock) > 0,
        args.wipe_inventory,
        args.import_items,
        args.items_to_char,
        args.item_levels,
        args.items_chaos_level is not None,
        args.clear_rerolls,
        args.backstory,
        args.fake_tvhm,
        args.delete_mission is not None,
        args.randomize_customizations,
        args.clear_lucky_dice,
        ])

    # Make changes
    if have_changes:

        if not args.quiet:
            print('Making requested changes...')
            print('')

        # Char Name
        if args.name:
            if not args.quiet:
                print(' - Setting Character Name to: {}'.format(args.name))
            save.set_char_name(args.name)

        # Savegame ID
        if args.save_game_id is not None:
            if not args.quiet:
                print(' - Setting Savegame ID to: {}'.format(args.save_game_id))
            save.set_savegame_id(args.save_game_id)

        # Savegame GUID
        if args.randomize_guid:
            if not args.quiet:
                print(' - Randomizing savegame GUID')
            save.randomize_guid()

        # Deleting missions
        # Doing this before fake_tvhm just so it doesn't error out if the user
        # specifies both.  Would be a bit silly to specify both but doesn't seem
        # worth setting up a mutually-exclusive constraint.
        if args.delete_mission is not None:
            for mission in args.delete_mission:
                if not args.quiet:
                    print(' - Deleting mission: {}'.format(mission))
                if not save.delete_mission(mission):
                    if not args.quiet:
                        print('   NOTE: Could not find mission to delete: {}'.format(
                            mission,
                            ))

        # Fake "TVHM"
        # Doing this before setting Chaos level so the user can set that too
        if args.fake_tvhm:
            if not args.quiet:
                print(' - Setting fake TVHM (resetting game state)')
            current_chaos_level, unlocked_chaos_level = save.get_chaos_level_with_max()
            save.set_playthroughs_completed(0)
            save.clear_playthrough_data()
            if args.tvhm_reset_overworld:
                if not args.quiet:
                    print('    - Also clearing Overworld bottlecaps/shrines')
                save.clear_overworld_challenges()
            else:
                # These two shrine challenges *do* need to be reset, otherwise the
                # Knife To Meet You quest gets broken.
                save.reset_challenge_obj('/Game/GameData/Challenges/Shrines/ShrinePieces/Diamond/Challenge_Crew_ShrinePiece_Diamond_02.Challenge_Crew_ShrinePiece_Diamond_02_C')
                save.reset_challenge_obj('/Game/GameData/Challenges/Shrines/ShrinePieceLocations/Challenge_Crew_ShrinePieceLocation_Diamond_2.Challenge_Crew_ShrinePieceLocation_Diamond_2_C')
            if unlocked_chaos_level > 0:
                # This'll undo our `set_playthroughs_completed` above, but eh.
                save.set_chaos_level(unlocked_chaos_level, unlock_only=True)
                save.set_chaos_level(current_chaos_level)

        # Chaos Level
        if args.chaos is not None:
            if not args.quiet:
                print(' - Setting Chaos Level to: {}'.format(args.chaos))
            save.set_chaos_level(args.chaos)

        # Level
        if args.level is not None:
            if not args.quiet:
                if args.xp_max:
                    extra = ' (at max XP value)'
                else:
                    extra = ''
                print(' - Setting Character Level{} to: {}'.format(extra, args.level))
            points_added = save.set_level(args.level, top_val=args.xp_max)
            if points_added > 0:
                if points_added == 1:
                    plural = ''
                else:
                    plural = 's'
                print(f'   - Also added {points_added} skill point{plural}')

        # Backstory
        if args.backstory:
            b = Backstory[args.backstory.upper()]
            if not args.quiet:
                print(f' - Setting backstory to: {b.label}')
            save.set_backstory(b)

        # Hero Stats - Specific value
        if args.hero_stats is not None:
            if not args.quiet:
                print(f' - Setting all Hero Stats to {args.hero_stats}')
            save.set_hero_stats(HeroStats, args.hero_stats)

        # Hero Stats - Individual stats
        if args.hero_stats_individual:
            for stat, value in args.hero_stats_individual.items():
                if not args.quiet:
                    print(f' - Setting {stat.label} Stat to: {value}')
                save.set_hero_stats(stat, value)

        # Money
        if args.money is not None:
            if not args.quiet:
                print(' - Setting Money to: {:,}'.format(args.money))
            save.set_money(args.money)

        # Moon Orbs
        if args.moon_orbs is not None:
            if not args.quiet:
                print(' - Setting Moon Orbs to: {:,}'.format(args.moon_orbs))
            save.set_moon_orbs(args.moon_orbs)

        # Lost Souls
        if args.souls is not None:
            if not args.quiet:
                print(' - Setting Lost Souls to: {:,}'.format(args.souls))
            save.set_souls(args.souls)

        # Clear Lucky Dice
        if args.clear_lucky_dice:
            if not args.quiet:
                print(' - Clearing Lucky Dice discoveries')
            save.clear_dice_challenges()
            save.clear_dice_interacts()

        # Unlocks
        if len(args.unlock) > 0:
            if not args.quiet:
                print(' - Processing Unlocks:')

            # Ammo
            if 'ammo' in args.unlock:
                if not args.quiet:
                    print('   - Ammo SDUs (and setting ammo to max)')
                save.set_max_sdus(ttwlsave.ammo_sdus)
                save.set_max_ammo()

            # Backpack
            if 'backpack' in args.unlock:
                if not args.quiet:
                    print('   - Backpack SDUs')
                save.set_max_sdus([SDU.BACKPACK])

            # Equipment Slots
            if 'equipslots' in args.unlock:
                if not args.quiet:
                    print('   - Equipment Slots')
                save.unlock_slots([
                    # Not unlocking the second spell slot since that's class-specific
                    InvSlot.WEAPON3,
                    InvSlot.WEAPON4,
                    InvSlot.ARMOR,
                    InvSlot.RING1,
                    InvSlot.RING2,
                    InvSlot.AMULET,
                    ])

            # Feats / Companions
            if 'feat' in args.unlock:
                if not args.quiet:
                    print('   - Feat / Companion')
                save.unlock_feat()

            # Multiclass
            if 'multiclass' in args.unlock:
                if not args.quiet:
                    print('   - Multiclass Capability')
                if save.unlock_multiclass():
                    if not args.quiet:
                        print('     - Also added +2 Skill Points')

            # Chaos Mode
            if 'chaos' in args.unlock or 'set_chaos' in args.unlock:
                if 'chaos' in args.unlock:
                    to_level = ttwlsave.max_chaos_level
                else:
                    # This would be technically already done when setting the active
                    # chaos level, but whatever.
                    to_level = args.chaos
                if not args.quiet:
                    print(f'   - Chaos Mode Level {to_level}')
                save.set_chaos_level(to_level, unlock_only=True)

        # Wipe Inventory
        if args.wipe_inventory:
            if not args.quiet:
                print(' - Wiping Inventory')
            save.wipe_inventory()

        # Import Items (cli_common provides the console output)
        if args.import_items:
            cli_common.import_items(args.import_items,
                    save.create_new_item_encoded,
                    save.add_item,
                    file_csv=args.csv,
                    quiet=args.quiet,
                    )

        # Setting item levels.  Keep in mind that we'll want to do this *after*
        # various of the actions above.  If we've been asked to up the level of
        # the character, we'll want items to follow suit, and if we've been asked
        # to change the level of items, we'll want to do it after the item import.
        # (cli_common provides the console output)
        if args.items_to_char or args.item_levels:
            if args.items_to_char:
                to_level = save.get_level()
            else:
                to_level = args.item_levels
            cli_common.update_item_levels(save.get_items(),
                    to_level,
                    quiet=args.quiet,
                    )

        # Setting item Chaos Levels (Chaotic, Volatile, etc...)
        # (cli_common provides the console output)
        if args.items_chaos_level is not None:
            cli_common.update_chaos_level(save.get_items(),
                    args.items_chaos_level,
                    quiet=args.quiet,
                    )

        # Clearing reroll counts (cli_common provides the console output)
        if args.clear_rerolls:
            cli_common.clear_rerolls(save.get_items(),
                    quiet=args.quiet,
                    )

        # Randomize customizations
        if args.randomize_customizations:
            if not args.quiet:
                if args.overdrive:
                    extra = ' (with Overdrive sliders)'
                else:
                    extra = ''
                print(f' - Randomizing Customizations{extra}')
            prof = TTWLProfile(args.randomize_customizations)
            if save.randomize_customizations(prof):
                save.randomize_appearance_sliders(args.overdrive)
        
        # Newline at the end of all this.
        if not args.quiet:
            print('')

    # Write out
    if args.output == 'savegame':
        save.save_to(args.output_filename)
        if not args.quiet:
            print('Wrote savegame to {}'.format(args.output_filename))
    elif args.output == 'protobuf':
        save.save_protobuf_to(args.output_filename)
        if not args.quiet:
            print('Wrote protobuf to {}'.format(args.output_filename))
    elif args.output == 'json':
        save.save_json_to(args.output_filename)
        if not args.quiet:
            print('Wrote JSON to {}'.format(args.output_filename))
    elif args.output == 'items':
        if args.csv:
            cli_common.export_items_csv(
                    save.get_items(),
                    args.output_filename,
                    quiet=args.quiet,
                    )
        else:
            cli_common.export_items(
                    save.get_items(),
                    args.output_filename,
                    quiet=args.quiet,
                    )
    else:
        # Not sure how we'd ever get here
        raise Exception('Invalid output format specified: {}'.format(args.output))


if __name__ == '__main__':
    main()
