#!/usr/bin/env python3
# SPDX-License-Identifier: BSD-3-Clause
#
# Texture lump builder for Freedoom
#
# This script builds the TEXTURE1, TEXTURE2 and PNAMES lumps for
# Freedoom - the lumps containing the texture definitions.
#
# Freedoom does not use deutex's built-in texture builder for several
# reasons:
#
# Firstly, Freedoom's texture lumps need to be compatible with those
# of the original Doom WADs. There are several examples of PWADs that
# replace only PNAMES, or only TEXTURE1. Because the TEXTURE1 format
# is tightly coupled to the ordering of the PNAMES lump, this means
# that these WADs will fail in Freedoom unless the ordering is
# strictly maintained. For example, if entry #3 in PNAMES is DOOR2_4,
# it must also be the same in Freedoom's version of PNAMES.
#
# Freedoom has a single configuration file where all its textures are
# defined, but doom.wad contains two separate lumps: TEXTURE1/TEXTURE2.
# Similarly to the first problem, it's important that the compatible
# lumps are built with the same textures in each: some WADs replace
# TEXTURE1 but not TEXTURE2, with the result that many textures end
# up missing.
#
# Finally, deutex does not allow a filename to be specified for
# TEXTURE entries. That is to say, you can't do this:
#
#     [textures]
#     TEXTURE1   = fdtxtr1.txt
#
# This is an annoying limitation that means that the different
# Freedoom IWADs cannot be built in parallel by make.

import collections
import os.path
import re
import sys
import struct

COMMENT_RE = re.compile(r"\s*;.*")
TEXTURE_NAME_RE = re.compile(r"\s*([\w-]+)\s+(-?\d+)\s+(-?\d+)")
PATCH_NAME_RE = re.compile(r"\s*\*\s+([\w-]+)\s+(-?\d+)\s+(-?\d+)")

Texture = collections.namedtuple("Texture", ["w", "h", "patches"])
TexturePatch = collections.namedtuple("TexturePatch", ["pname", "x", "y"])


class TextureSet(collections.OrderedDict):
    def __init__(self, pnames):
        """Initialize a new set of textures.

        Args:
          pnames: List of PNAMES to use for the textures. New
              patches will be added to this list as the texture
              set is extended.
        """
        super(TextureSet, self).__init__()
        self.pnames = pnames

    def pname_index(self, pname):
        """Get the index into the PNAMES list for the given patch.

        If the patch is not in the list, it will be added to the
        list.

        Args:
          pname: Name of the patch to look up.
        Returns:
          Index into the PNAMES list where this patch can be found.
        """
        pname = pname.upper()
        try:
            return self.pnames.index(pname)
        except ValueError:
            self.pnames.append(pname)
            return len(self.pnames) - 1

    def add_texture(self, name, width=0, height=0):
        """Add a new texture to the set.

        If a texture is already defined with the given name, the
        current definition is erased (though the ordering of
        textures is maintained).

        Args:
          name: Name of the texture.
          width: Width of the texture in pixels.
          height: Height of the texture in pixels.
        """
        name = name.upper()
        self[name] = Texture(width, height, [])

    def add_texture_patch(self, txname, patch, x, y):
        """Add a patch to the given texture.

        Args:
          txname: Name of the texture.
          patch: Name of the patch to add.
          x: X offset for the patch in pixels.
          y: Y offset for the patch in pixels.
        """
        txname = txname.upper()
        texture = self[txname]
        tp = TexturePatch(self.pname_index(patch), x, y)
        texture.patches.append(tp)

    def write_texture_lump(self, filename):
        """Build the texture lump and output to a file.

        Args:
          filename: Path to file in which to store the resulting
              lump.
        """
        with open(filename, "wb") as out:
            # Header indicating number of textures:
            out.write(struct.pack("<l", len(self)))

            # Offsets:
            offset = 4 + 4 * len(self)
            for _, texture in self.items():
                out.write(struct.pack("<l", offset))
                offset += 22 + 10 * len(texture.patches)

            # Write actual texture data:
            for name, texture in self.items():
                maptexture = struct.pack(
                    "<8slhhlh",
                    name.encode("ascii"),
                    0,
                    texture.w,
                    texture.h,
                    0,
                    len(texture.patches),
                )
                out.write(maptexture)

                # Patches:
                for patch in texture.patches:
                    mappatch = struct.pack(
                        "<hhhhh", patch.x, patch.y, patch.pname, 0, 0
                    )
                    out.write(mappatch)


class NamesFileError(Exception):
    pass


def read_names_file(filename):
    """Read a list of names from a text file.

    The names are a maximum of 8 characters long.
    Args:
      filename: Name of the file from which to read names.
    Returns:
      List of name strings, all in upper case.
    """
    with open(filename) as f:
        result = []
        for line in f:
            line = COMMENT_RE.sub("", line).strip()
            if len(line) > 8:
                raise NamesFileError(
                    "Invalid name in %s: %s" % (filename, line)
                )
            if line != "":
                result.append(line.upper())
        return result


def write_names_file(names, filename, sprites_dir):
    """Write a list of names to a file.

    Args:
      names: List of names to write.
      filename: Filename to write them to.
      sprites_dir: Path to directory containing sprites. If a file is found
          in this directory matching a patch name, that patch will not be
          included in the outputted list.
    """
    with open(filename, "w") as f:
        for name in names:
            filename = "%s.png" % name.lower()
            sprite_path = os.path.join(sprites_dir, filename)
            if not os.path.exists(sprite_path):
                f.write("%s\n" % name)


def load_compat_textures(textures, compat_file):
    """Pre-populate a texture set from a compatibility file.

    Args:
      textures: TextureSet to populate.
      compat_file: Path to text file containing list of textures. If
          None, do not do anything.
    """
    if compat_file is None:
        return

    for texture in read_names_file(compat_file):
        textures.add_texture(texture)


class TextureConfigError(Exception):
    pass


def parse_textures(stream):
    """Parse texture config from the given input stream.

    Args:
      stream: Input stream from which to read lines of input.
    Yields:
      A tuple for each parsed texture, containing:
        Texture name
        Width
        Height
        List of tuples representing each patch, where each contains:
          Patch name
          X offset
          Y offset
    """
    current_texture = None
    current_patches = []
    linenum = 0
    for line in sys.stdin:
        line = COMMENT_RE.sub("", line).strip()
        linenum += 1

        match = TEXTURE_NAME_RE.match(line)
        if match:
            if current_texture is not None:
                yield current_texture

            current_patches = []
            current_texture = (
                match.group(1),  # Texture name
                int(match.group(2)),  # Width
                int(match.group(3)),  # Height
                current_patches,  # List of patches
            )
            continue

        match = PATCH_NAME_RE.match(line)
        if match and current_texture:
            current_patches.append(
                (
                    match.group(1),  # Patch name
                    int(match.group(2)),  # X offset
                    int(match.group(3)),  # Y offset
                )
            )
            continue

        if line != "":
            raise TextureConfigError(
                "input:%i:Invalid config line: %s" % (linenum, line)
            )

    # Last texture:
    if current_texture is not None:
        yield current_texture


def write_pnames_lump(pnames, filename):
    """Write a PNAMES list to a file.

    Args:
      pnames: List of strings containing patch names.
      filename: Output filename.
    """
    with open(filename, "wb") as out:
        out.write(struct.pack("<l", len(pnames)))
        for pname in pnames:
            out.write(struct.pack("8s", pname.encode("ascii")))


def usage():
    print(
        """
Usage: %s -output_texture1=texture1.lmp -output_pnames=pnames.lmp < config.txt

Full list of arguments:
  -output_texture1: Path to the TEXTURE1 lump to generate (required).
  -output_texture2: Path to the TEXTURE2 lump to generate.
  -output_pnames: Path to the PNAMES lump to generate (required).
  -output_pnames_txt: Path to a text file to save a list of PNAMES.
  -sprites_dir: Path to the sprites directory, used to identify patches which
      are actually embedded sprites. These sprites will not be included in the
      outputted pnames file.
  -compat_texture1: File containing compatibility list of TEXTURE1 textures
  -compat_texture2: File containing compatibility list of TEXTURE2 textures
  -compat_pnames: File containing compatibility list of PNAMES
"""
    )
    sys.exit(1)


def parse_command_line(args):
    """Parse command line arguments.

    Args:
      args: List of command line arguments to parse.
    Returns:
      Dictionary mapping from arg name to value.
    """

    # Parse command line:
    valid_args = (
        "compat_texture1",
        "compat_texture2",
        "compat_pnames",
        "output_texture1",
        "output_texture2",
        "output_pnames",
        "output_pnames_txt",
        "sprites_dir",
    )
    result = {arg: None for arg in valid_args}

    for arg in args:
        if not arg.startswith("-") or "=" not in arg:
            usage()
        name, value = arg[1:].split("=", 2)
        if name not in valid_args:
            usage()
        result[name] = value

    # Required args:
    if not result["output_texture1"] or not result["output_pnames"]:
        usage()

    return result


args = parse_command_line(sys.argv[1:])

# If we have a compatibility PNAMES list, populate it. Otherwise PNAMES
# just starts from an empty list.
if args["compat_pnames"]:
    pnames = read_names_file(args["compat_pnames"])
else:
    pnames = []

# Generate basic data structures. The two texture lumps share a list
# of PNAMES.
texture1 = TextureSet(pnames)
texture2 = TextureSet(pnames)

# If we have compatibility lists for TEXTURE1/TEXTURE2, we need to
# populate the texture sets with them before we start parsing any real
# configuration.
load_compat_textures(texture1, args["compat_texture1"])
load_compat_textures(texture2, args["compat_texture2"])

# Parse the config file and store the texture data.
for texture, width, height, patches in parse_textures(sys.stdin):

    # If this texture was predefined for TEXTURE2, put it in that lump;
    # otherwise, one way or another it's going in TEXTURE1:
    if texture in texture2:
        textures = texture2
    else:
        textures = texture1

    textures.add_texture(texture, width, height)
    for patch, x, y in patches:
        textures.add_texture_patch(texture, patch, x, y)

# Write lumps to output files:
texture1.write_texture_lump(args["output_texture1"])
if args["output_texture2"]:
    texture2.write_texture_lump(args["output_texture2"])
write_pnames_lump(pnames, args["output_pnames"])
if args["output_pnames_txt"]:
    write_names_file(
        sorted(pnames), args["output_pnames_txt"], args["sprites_dir"]
    )
