#! /usr/bin/env python3
import argparse
import io
import json
import math
import os
import os.path
import sys
import textwrap
from PIL import Image, ImageDraw  # type: ignore
from typing import Any, Dict, List, Optional, Tuple, TypeVar

from bemani.format.afp import TXP2File, Shape, SWF, Frame, Tag, AP2DoActionTag, AP2PlaceObjectTag, AP2DefineSpriteTag, AFPRenderer, Color, Matrix
from bemani.format import IFS


def write_bytecode(swf: SWF, directory: str, *, verbose: bool) -> None:
    # Actually place the files down.
    os.makedirs(directory, exist_ok=True)

    # Buffer for where the decompiled data goes.
    buff: List[str] = []
    lut: Dict[str, int] = {}

    def bytecode_from_frames(frames: List[Frame]) -> None:
        for frame in frames:
            for tag in frame.imported_tags:
                if tag.init_bytecode:
                    buff.append(tag.init_bytecode.decompile(verbose=verbose))

    def bytecode_from_tags(tags: List[Tag]) -> None:
        for tag in tags:
            if isinstance(tag, AP2DoActionTag):
                buff.append(tag.bytecode.decompile(verbose=verbose))
            elif isinstance(tag, AP2PlaceObjectTag):
                for _, triggers in tag.triggers.items():
                    for trigger in triggers:
                        buff.append(trigger.decompile(verbose=verbose))
            elif isinstance(tag, AP2DefineSpriteTag):
                lut.update(tag.labels)
                bytecode_from_frames(tag.frames)
                bytecode_from_tags(tag.tags)

    lut.update(swf.labels)
    bytecode_from_frames(swf.frames)
    bytecode_from_tags(swf.tags)

    # If we have frame labels, put them at the top as global defines.
    if lut:
        buff = [
            os.linesep.join([
                '// Defined frame labels from animation container, as used for frame lookups.',
                'FRAME_LUT = {',
                *[f"    {name!r}: {frame}," for name, frame in lut.items()],
                '};',
            ]),
            *buff,
        ]

    # Now, write it out.
    filename = os.path.join(directory, swf.exported_name) + ".code"
    print(f"Writing code to {filename}...")
    with open(filename, "wb") as bfp:
        bfp.write(f"{os.linesep}{os.linesep}".join(buff).encode('utf-8'))


def parse_intlist(data: str) -> List[int]:
    ints: List[int] = []

    for chunk in data.split(","):
        chunk = chunk.strip()
        if '-' in chunk:
            start, end = chunk.split('-', 1)
            start_int = int(start.strip())
            end_int = int(end.strip())
            ints.extend(range(start_int, end_int + 1))
        else:
            ints.append(int(chunk))

    return sorted(set(ints))


def extract_txp2(
    fname: str,
    output_dir: str,
    *,
    split_textures: bool=False,
    generate_mapping_overlays: bool=False,
    write_mappings: bool=False,
    write_raw: bool=False,
    write_binaries: bool=False,
    pretend: bool=False,
    verbose: bool=False,
) -> int:
    if split_textures:
        if write_raw:
            raise Exception("Cannot write raw textures when splitting sprites!")
        if generate_mapping_overlays:
            raise Exception("Cannot generate mapping overlays when splitting sprites!")

    with open(fname, "rb") as bfp:
        afpfile = TXP2File(bfp.read(), verbose=verbose)

    # Actually place the files down.
    os.makedirs(output_dir, exist_ok=True)

    if not split_textures:
        for texture in afpfile.textures:
            filename = os.path.join(output_dir, texture.name)

            if texture.img:
                if pretend:
                    print(f"Would write {filename}.png texture...")
                else:
                    print(f"Writing {filename}.png texture...")
                    with open(f"{filename}.png", "wb") as bfp:
                        texture.img.save(bfp, format='PNG')

            if not texture.img or write_raw:
                if pretend:
                    print(f"Would write {filename}.raw texture...")
                else:
                    print(f"Writing {filename}.raw texture...")
                    with open(f"{filename}.raw", "wb") as bfp:
                        bfp.write(texture.raw)

                if pretend:
                    print(f"Would write {filename}.xml texture info...")
                else:
                    print(f"Writing {filename}.xml texture info...")
                    with open(f"{filename}.xml", "w") as sfp:
                        sfp.write(textwrap.dedent(f"""
                            <info>
                                <width>{texture.width}</width>
                                <height>{texture.height}</height>
                                <type>{hex(texture.fmt)}</type>
                                <raw>{filename}.raw</raw>
                            </info>
                        """).strip())

    if write_mappings:
        if not split_textures:
            for i, name in enumerate(afpfile.regionmap.entries):
                if i < 0 or i >= len(afpfile.texture_to_region):
                    raise Exception(f"Out of bounds region {i}")
                region = afpfile.texture_to_region[i]
                texturename = afpfile.texturemap.entries[region.textureno]
                filename = os.path.join(output_dir, name)

                if pretend:
                    print(f"Would write {filename}.xml region information...")
                else:
                    print(f"Writing {filename}.xml region information...")
                    with open(f"{filename}.xml", "w") as sfp:
                        sfp.write(textwrap.dedent(f"""
                            <info>
                                <left>{region.left}</left>
                                <top>{region.top}</top>
                                <right>{region.right}</right>
                                <bottom>{region.bottom}</bottom>
                                <texture>{texturename}</texture>
                            </info>
                        """).strip())

        if afpfile.fontdata is not None:
            filename = os.path.join(output_dir, "fontinfo.xml")

            if pretend:
                print(f"Would write {filename} font information...")
            else:
                print(f"Writing {filename} font information...")
                with open(filename, "w") as sfp:
                    sfp.write(str(afpfile.fontdata))

    if write_binaries:
        for i, name in enumerate(afpfile.swfmap.entries):
            swf = afpfile.swfdata[i]
            filename = os.path.join(output_dir, name)

            if pretend:
                print(f"Would write {filename}.afp animation data...")
                print(f"Would write {filename}.bsi animation descramble data...")
            else:
                print(f"Writing {filename}.afp animation data...")
                with open(f"{filename}.afp", "wb") as bfp:
                    bfp.write(swf.data)
                print(f"Writing {filename}.bsi animation descramble data...")
                with open(f"{filename}.bsi", "wb") as bfp:
                    bfp.write(swf.descramble_info)

        for i, name in enumerate(afpfile.shapemap.entries):
            shape = afpfile.shapes[i]
            filename = os.path.join(output_dir, f"{name}.geo")

            if pretend:
                print(f"Would write {filename} shape data...")
            else:
                print(f"Writing {filename} shape data...")
                with open(filename, "wb") as bfp:
                    bfp.write(shape.data)

    if generate_mapping_overlays:
        overlays: Dict[str, Any] = {}

        for i, name in enumerate(afpfile.regionmap.entries):
            if i < 0 or i >= len(afpfile.texture_to_region):
                raise Exception(f"Out of bounds region {i}")
            region = afpfile.texture_to_region[i]
            texturename = afpfile.texturemap.entries[region.textureno]

            if texturename not in overlays:
                for texture in afpfile.textures:
                    if texture.name == texturename:
                        overlays[texturename] = Image.new(
                            'RGBA',
                            (texture.width, texture.height),
                            (0, 0, 0, 0),
                        )
                        break
                else:
                    raise Exception(f"Couldn't find texture {texturename}")

            draw = ImageDraw.Draw(overlays[texturename])
            draw.rectangle(
                ((region.left // 2, region.top // 2), (region.right // 2, region.bottom // 2)),
                fill=(0, 0, 0, 0),
                outline=(255, 0, 0, 255),
                width=1,
            )
            draw.text(
                (region.left // 2, region.top // 2),
                name,
                fill=(255, 0, 255, 255),
            )

        for name, img in overlays.items():
            filename = os.path.join(output_dir, name) + "_overlay.png"
            if pretend:
                print(f"Would write {filename} overlay...")
            else:
                print(f"Writing {filename} overlay...")
                with open(filename, "wb") as bfp:
                    img.save(bfp, format='PNG')

    if split_textures:
        textures: Dict[str, Any] = {}
        announced: Dict[str, bool] = {}

        for i, name in enumerate(afpfile.regionmap.entries):
            if i < 0 or i >= len(afpfile.texture_to_region):
                raise Exception(f"Out of bounds region {i}")
            region = afpfile.texture_to_region[i]
            texturename = afpfile.texturemap.entries[region.textureno]

            if texturename not in textures:
                for tex in afpfile.textures:
                    if tex.name == texturename:
                        textures[texturename] = tex
                        break
                else:
                    raise Exception("Could not find texture {texturename} to split!")

            if textures[texturename].img:
                # Grab the location in the image, save it out to a new file.
                filename = f"{texturename}_{name}.png"
                filename = os.path.join(output_dir, filename)

                if pretend:
                    print(f"Would write {filename} sprite...")
                else:
                    print(f"Writing {filename} sprite...")
                    sprite = textures[texturename].img.crop(
                        (region.left // 2, region.top // 2, region.right // 2, region.bottom // 2),
                    )
                    with open(filename, "wb") as bfp:
                        sprite.save(bfp, format='PNG')
            else:
                if not announced.get(texturename, False):
                    print(f"Cannot extract sprites from {texturename} because it is not a supported format!")
                    announced[texturename] = True
    if write_bytecode:
        for swf in afpfile.swfdata:
            write_bytecode(swf, output_dir, verbose=verbose)

    return 0


def update_txp2(fname: str, update_dir: str, *, pretend: bool=False, verbose: bool=False) -> int:
    # First, parse the file out
    with open(fname, "rb") as bfp:
        afpfile = TXP2File(bfp.read(), verbose=verbose)

    # Now, find any PNG files that match texture names.
    for texture in afpfile.textures:
        filename = os.path.join(update_dir, texture.name) + ".png"

        if os.path.isfile(filename):
            print(f"Updating {texture.name} from {filename}...")

            with open(filename, "rb") as bfp:
                afpfile.update_texture(texture.name, bfp.read())

    # Now, find any PNG files that match a specific sprite.
    for i, spritename in enumerate(afpfile.regionmap.entries):
        if i < 0 or i >= len(afpfile.texture_to_region):
            raise Exception(f"Out of bounds region {i}")
        region = afpfile.texture_to_region[i]
        texturename = afpfile.texturemap.entries[region.textureno]

        # Grab the location in the image to see if it exists.
        filename = f"{texturename}_{spritename}.png"
        filename = os.path.join(update_dir, filename)

        if os.path.isfile(filename):
            print(f"Updating {texturename} sprite piece {spritename} from {filename}...")

            with open(filename, "rb") as bfp:
                afpfile.update_sprite(texturename, spritename, bfp.read())

    # Now, write out the updated file
    if pretend:
        print(f"Would write {fname}...")
        afpfile.unparse()
    else:
        print(f"Writing {fname}...")
        data = afpfile.unparse()
        with open(fname, "wb") as bfp:
            bfp.write(data)

    return 0


def print_txp2(fname: str, *, decompile_bytecode: bool=False, verbose: bool=False) -> int:
    # First, parse the file out
    with open(fname, "rb") as bfp:
        afpfile = TXP2File(bfp.read(), verbose=verbose)

    # Now, print it
    print(json.dumps(afpfile.as_dict(decompile_bytecode=decompile_bytecode, verbose=verbose), sort_keys=True, indent=4))

    return 0


def parse_afp(afp: str, bsi: str, *, decompile_bytecode: bool=False, verbose: bool=False) -> int:
    # First, load the AFP and BSI files
    with open(afp, "rb") as bafp:
        with open(bsi, "rb") as bbsi:
            swf = SWF("<unnamed>", bafp.read(), bbsi.read())

    # Now, print it
    swf.parse(verbose=verbose)
    print(json.dumps(swf.as_dict(decompile_bytecode=decompile_bytecode, verbose=verbose), sort_keys=True, indent=4))

    return 0


def decompile_afp(afp: str, bsi: str, output_dir: str, *, verbose: bool=False) -> int:
    # First, load the AFP and BSI files
    with open(afp, "rb") as bafp:
        with open(bsi, "rb") as bbsi:
            swf = SWF("<unnamed>", bafp.read(), bbsi.read())

    # Now, decompile it
    swf.parse(verbose=verbose)
    write_bytecode(swf, output_dir, verbose=verbose)

    return 0


def parse_geo(geo: str, *, verbose: bool=False) -> int:
    # First, load the AFP and BSI files
    with open(geo, "rb") as bfp:
        shape = Shape("<unnamed>", bfp.read())

    # Now, print it
    shape.parse()
    if verbose:
        print(shape, file=sys.stderr)
    print(json.dumps(shape.as_dict(), sort_keys=True, indent=4))

    return 0


def load_containers(renderer: AFPRenderer, containers: List[str], *, need_extras: bool, verbose: bool) -> None:
    # This is a complicated one, as we need to be able to specify multiple
    # directories of files as well as support IFS files and TXP2 files.
    for container in containers:
        with open(container, "rb") as bfp:
            data = bfp.read()

        afpfile = None
        try:
            afpfile = TXP2File(data, verbose=verbose)
        except Exception:
            pass

        if afpfile is not None:
            if verbose:
                print(f"Loading files out of TXP2 container {container}...", file=sys.stderr)

            if need_extras:
                # First, load GE2D structures into the renderer.
                for i, name in enumerate(afpfile.shapemap.entries):
                    shape = afpfile.shapes[i]
                    renderer.add_shape(name, shape)

                    if verbose:
                        print(f"Added {name} to animation shape library.", file=sys.stderr)

                # Now, split and load textures into the renderer.
                sheets: Dict[str, Any] = {}

                for i, name in enumerate(afpfile.regionmap.entries):
                    if i < 0 or i >= len(afpfile.texture_to_region):
                        raise Exception(f"Out of bounds region {i}")
                    region = afpfile.texture_to_region[i]
                    texturename = afpfile.texturemap.entries[region.textureno]

                    if texturename not in sheets:
                        for tex in afpfile.textures:
                            if tex.name == texturename:
                                sheets[texturename] = tex
                                break
                        else:
                            raise Exception("Could not find texture {texturename} to split!")

                    if sheets[texturename].img:
                        sprite = sheets[texturename].img.crop(
                            (region.left // 2, region.top // 2, region.right // 2, region.bottom // 2),
                        )
                        renderer.add_texture(name, sprite)

                        if verbose:
                            print(f"Added {name} to animation texture library.", file=sys.stderr)
                    else:
                        print(f"Cannot load {name} from {texturename} because it is not a supported format!")

            # Finally, load the animation data itself into the renderer.
            for i, name in enumerate(afpfile.swfmap.entries):
                swf = afpfile.swfdata[i]
                renderer.add_swf(name, swf)

                if verbose:
                    print(f"Added {name} to animation library.", file=sys.stderr)

            continue

        ifsfile = None
        try:
            ifsfile = IFS(data, decode_textures=True)
        except Exception:
            pass

        if ifsfile is not None:
            if verbose:
                print(f"Loading files out of IFS container {container}...", file=sys.stderr)
            for fname in ifsfile.filenames:
                if fname.startswith(f"geo{os.sep}"):
                    if not need_extras:
                        continue

                    # Trim off directory.
                    shapename = fname[(3 + len(os.sep)):]

                    # Load file, register it.
                    fdata = ifsfile.read_file(fname)
                    shape = Shape(shapename, fdata)
                    renderer.add_shape(shapename, shape)

                    if verbose:
                        print(f"Added {shapename} to animation shape library.", file=sys.stderr)
                elif fname.startswith(f"tex{os.sep}") and fname.endswith(".png"):
                    if not need_extras:
                        continue

                    # Trim off directory, png extension.
                    texname = fname[(3 + len(os.sep)):][:-4]

                    # Load file, register it.
                    fdata = ifsfile.read_file(fname)
                    tex = Image.open(io.BytesIO(fdata))
                    renderer.add_texture(texname, tex)

                    if verbose:
                        print(f"Added {texname} to animation texture library.", file=sys.stderr)
                elif fname.startswith(f"afp{os.sep}"):
                    # Trim off directory, see if it has a corresponding bsi.
                    afpname = fname[(3 + len(os.sep)):]
                    bsipath = f"afp{os.sep}bsi{os.sep}{afpname}"

                    if bsipath in ifsfile.filenames:
                        afpdata = ifsfile.read_file(fname)
                        bsidata = ifsfile.read_file(bsipath)
                        flash = SWF(afpname, afpdata, bsidata)
                        renderer.add_swf(afpname, flash)

                        if verbose:
                            print(f"Added {afpname} to animation library.", file=sys.stderr)
            continue


def list_paths(containers: List[str], *, include_frames: bool=False, include_size: bool=False, verbose: bool=False) -> int:
    renderer = AFPRenderer()
    load_containers(renderer, containers, need_extras=False, verbose=verbose)

    for path in renderer.list_paths(verbose=verbose):
        display = path
        if include_size:
            location = renderer.compute_path_size(path)
            display = f"{display} - {int(location.width)}x{int(location.height)}"
        if include_frames:
            frames = renderer.compute_path_frames(path)
            display = f"{display} - {frames} frames"
        print(display)

    return 0


BackgroundT = TypeVar("BackgroundT")


def adjust_background_loop(
    background: List[BackgroundT],
    background_loop_start: Optional[int] = None,
    background_loop_end: Optional[int] = None,
    background_loop_offset: Optional[int] = None,
) -> List[BackgroundT]:
    # Make sure background frames are 1-indexed here as well.
    if background_loop_start is None:
        background_loop_start = 0
    else:
        background_loop_start -= 1

    if background_loop_offset is None:
        background_loop_offset = 0
    else:
        background_loop_offset -= (background_loop_start + 1)

    # Don't one-index the end because we want it to be inclusive.
    if background_loop_end is None:
        background_loop_end = len(background)

    if background_loop_start >= background_loop_end:
        raise Exception("Cannot start background loop after the end of the background loop!")
    if background_loop_start < 0 or background_loop_end < 0:
        raise Exception("Cannot start or end background loop on a negative frame!")
    if background_loop_start >= len(background) or background_loop_end > len(background):
        raise Exception("Cannot start or end background loop larger than the number of background animation frames!")

    background = background[background_loop_start:background_loop_end]

    if background_loop_offset < 0 or background_loop_offset >= len(background):
        raise Exception("Cannot start first iteration of background loop outside the loop bounds!")

    return background[background_loop_offset:] + background[:background_loop_offset]


def render_path(
    containers: List[str],
    path: str,
    output: str,
    *,
    disable_threads: bool = False,
    enable_anti_aliasing: bool = False,
    background_color: Optional[str] = None,
    background_image: Optional[str] = None,
    background_loop_start: Optional[int] = None,
    background_loop_end: Optional[int] = None,
    background_loop_offset: Optional[int] = None,
    force_width: Optional[int] = None,
    force_height: Optional[int] = None,
    force_aspect_ratio: Optional[str] = None,
    scale_width: float = 1.0,
    scale_height: float = 1.0,
    only_depths: Optional[str] = None,
    only_frames: Optional[str] = None,
    verbose: bool = False,
    show_progress: bool = False,
) -> int:
    if show_progress:
        print("Loading textures, shapes and animation instructions...")

    renderer = AFPRenderer(single_threaded=disable_threads, enable_aa=enable_anti_aliasing)
    load_containers(renderer, containers, need_extras=True, verbose=verbose)

    if show_progress:
        print("Calculating render parameters...")

    # Verify the correct params.
    if output.lower().endswith(".gif"):
        fmt = "GIF"
    elif output.lower().endswith(".webp"):
        fmt = "WEBP"
    elif output.lower().endswith(".png"):
        fmt = "PNG"
    else:
        raise Exception("Unrecognized file extension for output!")

    # Allow overriding background color.
    if background_color:
        colorvals = background_color.split(",")
        if len(colorvals) not in [3, 4]:
            raise Exception("Invalid color, specify a color as a comma-separated RGB or RGBA value!")

        if len(colorvals) == 3:
            colorvals.append("255")
        colorints = [int(c.strip()) for c in colorvals]
        for c in colorints:
            if c < 0 or c > 255:
                raise Exception("Color values should be between 0 and 255!")

        color = Color(*[c / 255.0 for c in colorints])
    else:
        color = None

    # Allow inserting a background image, series of images or animation.
    if background_image:
        background_image = os.path.abspath(background_image)
        background: List[Image.Image] = []

        if os.path.isfile(background_image):
            # This is a direct reference, open it.
            with open(background_image, "rb") as bfp:
                # Work around the fact that PIL does not read the image until first use,
                # meaning a long background image sequence can blow past max open files.
                bgimg = Image.open(io.BytesIO(bfp.read()))
            frames = getattr(bgimg, "n_frames", 1)

            if frames == 1:
                background.append(bgimg)
            elif frames > 1:
                for frame in range(frames):
                    bgimg.seek(frame)
                    background.append(bgimg.copy())
            else:
                raise Exception("Invalid image specified as background!")
        else:
            # This is probably a reference to a list of images.
            dirof, fileof = os.path.split(background_image)
            startof, endof = os.path.splitext(fileof)
            if len(startof) == 0 or len(endof) == 0:
                raise Exception("Invalid image specified as background!")
            startof = startof + '-'

            # Gather up the sequence of files so we can make frames out of them.
            seqdict: Dict[int, str] = {}
            for filename in os.listdir(dirof):
                if filename.startswith(startof) and filename.endswith(endof):
                    seqno = filename[len(startof):(-len(endof))]
                    if seqno.isdigit():
                        seqint = int(seqno)
                        if seqint in seqdict:
                            raise Exception(f"{filename} specifies the same background frame number as {seqdict[seqint]}!")
                        seqdict[seqint] = filename

            # Now, order the sequence by the integer of the sequence number so we can load the images.
            seqtuple: List[Tuple[int, str]] = sorted(
                [(s, p) for (s, p) in seqdict.items()],
                key=lambda e: e[0],
            )

            # Finally, get the filenames from this sequence.
            filenames: List[str] = [os.path.join(dirof, filename) for (_, filename) in seqtuple]

            # Now that we have the list, lets load the images!
            for filename in filenames:
                with open(filename, "rb") as bfp:
                    # Work around the fact that PIL does not read the image until first use,
                    # meaning a long background image sequence can blow past max open files.
                    bgimg = Image.open(io.BytesIO(bfp.read()))
                frames = getattr(bgimg, "n_frames", 1)

                if frames == 1:
                    background.append(bgimg)
                elif frames > 1:
                    for frame in range(frames):
                        bgimg.seek(frame)
                        background.append(bgimg.copy())
                else:
                    raise Exception("Invalid image specified as background!")

        if background:
            background = adjust_background_loop(background, background_loop_start, background_loop_end, background_loop_offset)
        else:
            raise Exception("Did not find any background images to load!")
    else:
        background = None

    # Calculate the size of the animation so we can apply scaling options.
    swf_location = renderer.compute_path_location(path)
    requested_width = force_width if force_width is not None else swf_location.width
    requested_height = force_height if force_height is not None else swf_location.height

    # Allow overriding the aspect ratio.
    if force_aspect_ratio:
        ratio = force_aspect_ratio.split(":")
        if len(ratio) != 2:
            raise Exception("Invalid aspect ratio, specify a ratio such as 16:9 or 4:3!")

        rx, ry = [float(r.strip()) for r in ratio]
        if rx <= 0 or ry <= 0:
            raise Exception("Ratio must only include positive numbers!")

        actual_ratio = rx / ry
        swf_ratio = swf_location.width / swf_location.height

        if abs(swf_ratio - actual_ratio) > 0.0001:
            new_width = actual_ratio * swf_location.height
            new_height = swf_location.width / actual_ratio

            if new_width < swf_location.width and new_height < swf_location.height:
                raise Exception("Impossible aspect ratio!")
            if new_width > swf_location.width and new_height > swf_location.height:
                raise Exception("Impossible aspect ratio!")

            # We know that one is larger and one is smaller, pick the larger.
            # This way we always stretch instead of shrinking.
            if new_width > swf_location.width:
                requested_width = new_width
            else:
                requested_height = new_height

    # Finally, apply requested final scaling.
    requested_width *= scale_width
    requested_height *= scale_height

    # Calculate the overall view matrix based on the requested width/height.
    transform = Matrix.affine(
        a=requested_width / swf_location.width,
        b=0.0,
        c=0.0,
        d=requested_height / swf_location.height,
        tx=0.0,
        ty=0.0,
    )

    # Support rendering only certain depth planes.
    if only_depths is not None:
        requested_depths = parse_intlist(only_depths)
    else:
        requested_depths = None

    # Support rendering only certain frames.
    if only_frames is not None:
        requested_frames = parse_intlist(only_frames)
    else:
        requested_frames = None

    if fmt in ["GIF", "WEBP"]:
        # Write all the frames out in one file.
        duration = renderer.compute_path_frame_duration(path)
        frames = renderer.compute_path_frames(path)
        images: List[Image] = []
        for i, img in enumerate(
            renderer.render_path(
                path,
                verbose=verbose,
                background_color=color,
                background_image=background,
                only_depths=requested_depths,
                only_frames=requested_frames,
                movie_transform=transform,
            )
        ):
            if show_progress:
                frameno = requested_frames[i] if requested_frames is not None else (i + 1)
                print(f"Rendered animation frame {frameno}/{frames}.")
            images.append(img)

        if len(images) > 0:
            try:
                dirof = os.path.dirname(os.path.abspath(output))
                os.makedirs(dirof, exist_ok=True)
            except FileNotFoundError:
                # Apparently on OSX this is possible?
                pass

            with open(output, "wb") as bfp:
                images[0].save(bfp, format=fmt, save_all=True, append_images=images[1:], duration=duration, optimize=True)

            print(f"Wrote animation to {output}")
    else:
        # Write all the frames out in individual_files.
        filename = output[:-4]
        ext = output[-4:]

        # Figure out padding for the images.
        frames = renderer.compute_path_frames(path)
        if frames > 0:
            digits = f"0{int(math.log10(frames)) + 1}"

            for i, img in enumerate(
                renderer.render_path(
                    path,
                    verbose=verbose,
                    background_color=color,
                    background_image=background,
                    only_depths=requested_depths,
                    only_frames=requested_frames,
                    movie_transform=transform,
                )
            ):
                frameno = requested_frames[i] if requested_frames is not None else (i + 1)
                fullname = f"{filename}-{frameno:{digits}}{ext}"

                try:
                    dirof = os.path.dirname(os.path.abspath(fullname))
                    os.makedirs(dirof, exist_ok=True)
                except FileNotFoundError:
                    # Apparently on OSX this is possible?
                    pass

                with open(fullname, "wb") as bfp:
                    img.save(bfp, format=fmt)

                print(f"Wrote animation frame to {fullname}")

    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Konami AFP graphic file unpacker/repacker.")
    subparsers = parser.add_subparsers(help='Action to take', dest='action')

    extract_parser = subparsers.add_parser(
        'extract',
        help='Extract relevant file data and textures from a TXP2 container',
        description="Extract textures, sprites, decompiled bytecode, AFP, BSI and GEO files from a TXP2 container.",
    )
    extract_parser.add_argument(
        "file",
        metavar="FILE",
        help="The TXP2 container to extract",
    )
    extract_parser.add_argument(
        "dir",
        metavar="DIR",
        help="The directory to extract all contents to",
    )
    extract_parser.add_argument(
        "-p",
        "--pretend",
        action="store_true",
        help="Pretend to extract instead of extracting, printing what would have been extracted",
    )
    extract_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )
    extract_parser.add_argument(
        "-r",
        "--write-raw",
        action="store_true",
        help="Always write raw texture data instead of only writing raw texture data for unrecognized texture formats",
    )
    extract_parser.add_argument(
        "-m",
        "--write-mappings",
        action="store_true",
        help="Write mapping files to disk as XML files",
    )
    extract_parser.add_argument(
        "-g",
        "--generate-mapping-overlays",
        action="store_true",
        help="Generate overlay images showing mappings between textures and individual sprites",
    )
    extract_parser.add_argument(
        "-s",
        "--split-textures",
        action="store_true",
        help="Split textures into individual sprite image files",
    )
    extract_parser.add_argument(
        "-b",
        "--write-binaries",
        action="store_true",
        help="Write raw AFP/BSI/GEO files to disk",
    )
    extract_parser.add_argument(
        "-y",
        "--write-bytecode",
        action="store_true",
        help="Write decompiled bytecode files found in AFP files to disk",
    )

    update_parser = subparsers.add_parser(
        'update',
        help='Update relevant textures in a TXP2 container from a directory',
        description="Update textures and sprites in a TXP2 container based on images in a directory.",
    )
    update_parser.add_argument(
        "file",
        metavar="FILE",
        help="The TXP2 container to update",
    )
    update_parser.add_argument(
        "dir",
        metavar="DIR",
        help="Directory to update all contents from",
    )
    update_parser.add_argument(
        "-p",
        "--pretend",
        action="store_true",
        help="Pretend to update instead of updating, printing what would have been updated",
    )
    update_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )

    print_parser = subparsers.add_parser(
        'print',
        help='Print a TXP2 container\'s contents as a JSON dictionary',
        description='Print a TXP2 container\'s contents as a JSON dictionary.',
    )
    print_parser.add_argument(
        "file",
        metavar="FILE",
        help="The TXP2 container to print",
    )
    print_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )
    print_parser.add_argument(
        "-d",
        "--decompile-bytecode",
        action="store_true",
        help="Attempt to decompile and print pseudocode instead of printing the raw bytecode.",
    )

    parseafp_parser = subparsers.add_parser(
        'parseafp',
        help='Parse a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container',
        description='Parse a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container.',
    )
    parseafp_parser.add_argument(
        "afp",
        metavar="AFPFILE",
        help="The AFP file to parse",
    )
    parseafp_parser.add_argument(
        "bsi",
        metavar="BSIFILE",
        help="The BSI file to parse",
    )
    parseafp_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )
    parseafp_parser.add_argument(
        "-d",
        "--decompile-bytecode",
        action="store_true",
        help="Attempt to decompile and print pseudocode instead of printing the raw bytecode.",
    )

    decompile_parser = subparsers.add_parser(
        'decompile',
        help='Decompile bytecode in a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container',
        description='Decompile bytecode in a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container.',
    )
    decompile_parser.add_argument(
        "afp",
        metavar="AFPFILE",
        help="The AFP file to parse",
    )
    decompile_parser.add_argument(
        "bsi",
        metavar="BSIFILE",
        help="The BSI file to parse",
    )
    decompile_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )
    decompile_parser.add_argument(
        "-d",
        "--directory",
        metavar="DIR",
        default='.',
        type=str,
        help="Directory to write decompiled pseudocode files. Defaults to current directory.",
    )

    parsegeo_parser = subparsers.add_parser(
        'parsegeo',
        help='Parse a raw GEO file previously extracted from an IFS or TXP2 container',
        description='Parse a raw GEO file previously extracted from an IFS or TXP2 container.',
    )
    parsegeo_parser.add_argument(
        "geo",
        metavar="GEOFILE",
        help="The GEO file to parse",
    )
    parsegeo_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )

    render_parser = subparsers.add_parser(
        'render',
        help='Render a particular animation out of a collection of TXP2 or IFS containers',
        description='Render a particular animation out of a collection of TXP2 or IFS containers.',
    )
    render_parser.add_argument(
        "container",
        metavar="CONTAINER",
        type=str,
        nargs='+',
        help="A container to use for loading animation data. Can be either a TXP2 or IFS container.",
    )
    render_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )
    render_parser.add_argument(
        "-s",
        "--show-progress",
        action="store_true",
        help="Display per-frame rendering progress",
    )
    render_parser.add_argument(
        "--disable-threads",
        action="store_true",
        help="Disable multi-threaded rendering. The animation will be rendered on a single core and threads will not be spawned.",
    )
    render_parser.add_argument(
        "--path",
        metavar="PATH",
        type=str,
        required=True,
        help='The path of the animation to render. Use the "list" command to discover paths in a container.',
    )
    render_parser.add_argument(
        "--output",
        metavar="IMAGE",
        type=str,
        default="out.gif",
        help=(
            'The output file (ending either in .gif, .webp or .png) where the render should be saved. If .png is chosen then the '
            'output will be a series of png files for each rendered frame. If .gif or .webp is chosen the output will be an '
            'animated image. Note that the .gif file format has several severe limitations which result in sub-optimal animations '
            'so it is recommended to use .webp or .png instead.'
        ),
    )
    render_parser.add_argument(
        "--background-color",
        type=str,
        default=None,
        help=(
            "Set the background color of the animation as a comma-separated RGB or RGBA color (such as 255,0,0 for red or "
            "0,255,0,128 for translucent green), overriding any default in the animation."
        ),
    )
    render_parser.add_argument(
        "--background-image",
        type=str,
        default=None,
        help=(
            "Set a background image or animation to be placed behind the animation but in front of the background color. "
            "Note that the background will be stretched to fit the animation. If a .png is specified and multiple rendered "
            "frames are present it will use that series as an animation. If a static image is specified and mulitple frames "
            "are not present it will use that as a static background. If an animated image is specified it will use that "
            "as an animated background."
        ),
    )
    render_parser.add_argument(
        "--background-loop-start",
        type=int,
        default=None,
        help=(
            "The starting frame of the background animation loop. Specify this to loop to a background animation frame other "
            "than the first. For example, if your background animation has 10 frames and you specify a loop start of 6, the "
            "resulting background animation loop will contain frames 6, 7, 8, 9 and 10 played in that order."
        ),
    )
    render_parser.add_argument(
        "--background-loop-end",
        type=int,
        default=None,
        help=(
            "The ending frame of the background animation loop. Specify this to loop from a background animation frame other "
            "than the last. For example, if your background animation has 10 frames and you specify a loop end of 8, the "
            "resulting background animation loop will contain frames 1, 2, 3, 4, 5, 6, 7 and 8 played in that order."
        ),
    )
    render_parser.add_argument(
        "--background-loop-offset",
        type=int,
        default=None,
        help=(
            "The very first frame of the background animation. Specify this to start the first loop anywhere other than the "
            "loop start frame. For example, if your background animation has 10 frames and you specify a loop offset of 7, the "
            "resulting background animation loop will contain frames 7, 8, 9 10, 1, 2, 3, 4, 5 and 6 played in that order. Note "
            "that this can work in conjunction with the --background-loop-start and --background-loop-end parameters. For "
            "example, if your background animation has 10 frames and you specify a loop start of 3, a loop end of 7 and a loop "
            "offset of 5, the resulting background animation loop will contain frames 5, 6, 7, 3 and 4 played in that order."
        ),
    )
    render_parser.add_argument(
        "--only-depths",
        type=str,
        default=None,
        help="Only render objects on these depth planes. Specify a number, a comma-separated list of numbers or a range such as 3-5.",
    )
    render_parser.add_argument(
        "--only-frames",
        type=str,
        default=None,
        help=(
            "Only render these frames. Specify a number, a comma-separated list of numbers or a range such as 10-20. Note that the "
            "first frame is frame 1, not frame 0."
        ),
    )
    render_parser.add_argument(
        "--force-width",
        type=int,
        default=None,
        help=(
            "Force the width of the rendered animation to a specific pixel value, such as 640 or 800. If the forced width does not match "
            "the animation's original width it will be stretched horizontally."
        ),
    )
    render_parser.add_argument(
        "--force-height",
        type=int,
        default=None,
        help=(
            "Force the height of the rendered animation to a specific pixel value, such as 480 or 600. If the forced height does not match "
            "the animation's original height it will be stretched vertically."
        ),
    )
    render_parser.add_argument(
        "--force-aspect-ratio",
        type=str,
        default=None,
        help=(
            "Force the aspect ratio of the rendered animation, as a colon-separated aspect ratio such as 16:9 or 4:3, after applying "
            "any forced width and height. The resulting animation will be stretched to meet the requested aspect ratio."
        ),
    )
    render_parser.add_argument(
        "--scale-width",
        type=float,
        default=1.0,
        help=(
            "Scale the final width of the animation by some factor, such as 2.0 or 0.5, after applying any forced width, height and "
            "aspect ratio. The resulting animation will be stretched horizontally by the scaling factor."
        ),
    )
    render_parser.add_argument(
        "--scale-height",
        type=float,
        default=1.0,
        help=(
            "Scale the final height of the animation by some factor, such as 2.0 or 0.5, after applying any forced width, height and "
            "aspect ratio. The resulting animation will be stretched vertically by the scaling factor."
        ),
    )
    render_parser.add_argument(
        "--enable-anti-aliasing",
        action="store_true",
        help="Enable anti-aliased rendering, using bilinear interpolation and super-sampling where appropriate to produce the best resulting animation.",
    )

    list_parser = subparsers.add_parser(
        'list',
        help='List out the possible paths to render from a collection of TXP2 or IFS containers',
        description='List out the possible paths to render from a collection of TXP2 or IFS containers.',
    )
    list_parser.add_argument(
        "container",
        metavar="CONTAINER",
        type=str,
        nargs='+',
        help="A container to use for loading animation data. Can be either a TXP2 or IFS container.",
    )
    list_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Display verbose debugging output",
    )
    list_parser.add_argument(
        "--include-frames",
        action="store_true",
        help="Include number of frames per animation in the output list.",
    )
    list_parser.add_argument(
        "--include-size",
        action="store_true",
        help="Include width/height per animation in the output list.",
    )

    args = parser.parse_args()

    if args.action == "extract":
        return extract_txp2(
            args.file,
            args.dir,
            split_textures=args.split_textures,
            generate_mapping_overlays=args.generate_mapping_overlays,
            write_mappings=args.write_mappings,
            write_raw=args.write_raw,
            write_binaries=args.write_binaries,
            pretend=args.pretend,
            verbose=args.verbose,
        )
    elif args.action == "update":
        return update_txp2(args.file, args.dir, pretend=args.pretend, verbose=args.verbose)
    elif args.action == "print":
        return print_txp2(args.file, decompile_bytecode=args.decompile_bytecode, verbose=args.verbose)
    elif args.action == "parseafp":
        return parse_afp(args.afp, args.bsi, decompile_bytecode=args.decompile_bytecode, verbose=args.verbose)
    elif args.action == "decompile":
        return decompile_afp(args.afp, args.bsi, args.directory, verbose=args.verbose)
    elif args.action == "parsegeo":
        return parse_geo(args.geo, verbose=args.verbose)
    elif args.action == "list":
        return list_paths(args.container, include_size=args.include_size, include_frames=args.include_frames, verbose=args.verbose)
    elif args.action == "render":
        return render_path(
            args.container,
            args.path,
            args.output,
            disable_threads=args.disable_threads,
            enable_anti_aliasing=args.enable_anti_aliasing,
            background_color=args.background_color,
            background_image=args.background_image,
            background_loop_start=args.background_loop_start,
            background_loop_end=args.background_loop_end,
            background_loop_offset=args.background_loop_offset,
            force_width=args.force_width,
            force_height=args.force_height,
            force_aspect_ratio=args.force_aspect_ratio,
            scale_width=args.scale_width,
            scale_height=args.scale_height,
            only_depths=args.only_depths,
            only_frames=args.only_frames,
            show_progress=args.show_progress,
            verbose=args.verbose,
        )
    else:
        raise Exception(f"Invalid action {args.action}!")


if __name__ == "__main__":
    sys.exit(main())
