#!/usr/bin/env python3
"""faust2clap - compile a Faust DSP program into a CLAP plugin.

This tool is written in Python while its siblings in this directory are bash
scripts. That is a deliberate choice, not an accident: the CLAP build is
driven by CMake and needs structured metadata (JSON from `faust -json`), which
Python handles without the quoting gymnastics bash would require. The cost is
that it cannot `source faustpath` / `usage.sh` like the others, so the
equivalents are provided here.

Generated sources and the build tree go next to the input `.dsp`, as in every
sibling tool, under a "-clap" suffixed directory so they cannot collide with an
executable another faust2xx tool built from the same program.

    foo.dsp
    foo-clap/
      foo_clap.cpp        generated by `faust -a clap-arch.cpp`
      plugin_metadata.h   generated from the DSP metadata
      CMakeLists.txt      generated, builds exactly this one plugin
      build/              CMake working directory
"""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys

ARCH_DIR_REL = "clap"
ARCH_REL_PATH = ARCH_DIR_REL + "/clap-arch.cpp"

# Compiled into every plugin alongside the generated DSP: explicit template
# instantiations for the CLAP helpers, and the definitions of Faust's GUI
# statics. They live in architecture/clap so that an installed Faust carries
# them, like the architecture file itself.
SUPPORT_SOURCES = ("clap-plugin-impl.cpp", "clap-gui-glue.cpp")


# --- error reporting -------------------------------------------------------
# The family convention is a one-line diagnostic and a non-zero exit, never a
# language-level traceback: these are user errors, not tool bugs.

def die(message, *hints):
    print(f"faust2clap: {message}", file=sys.stderr)
    for hint in hints:
        print(f"  {hint}", file=sys.stderr)
    sys.exit(1)


def info(message):
    # Flushed because die() writes to stderr, which is unbuffered: without this
    # the two streams interleave out of order as soon as output is redirected,
    # and a progress line appears after the failure it preceded.
    print(f"faust2clap: {message}", flush=True)


# --- the Python equivalent of `faustpath` ----------------------------------

def faust_dir(flag):
    """Asks the installed compiler for one of its directories."""
    try:
        return subprocess.check_output(["faust", flag], text=True).strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None


def find_arch_file(script_dir):
    """Locates the CLAP architecture directory.

    Returns `(clap-arch.cpp, containing directory)`.

    A checkout is preferred over an installation: running the script from a
    source tree means working on that tree, and picking up an installed
    architecture instead would silently test something else. When the script is
    installed, the checkout-relative candidate simply does not exist and the
    installed directory is used.

    A directory that has `clap-arch.cpp` but is missing the support sources is
    skipped rather than fatal — that is what an installation predating their
    move looks like, and the next candidate may well be complete.
    """
    candidates = [
        os.path.join(script_dir, "..", "..", "architecture", ARCH_REL_PATH),
    ]
    archdir = faust_dir("--archdir")
    if archdir:
        candidates.append(os.path.join(archdir, ARCH_REL_PATH))
    if os.environ.get("FAUST_LIB"):
        candidates.append(os.path.join(os.environ["FAUST_LIB"], ARCH_REL_PATH))
    for prefix in ("/usr/local/share/faust", "/usr/share/faust",
                   "/opt/homebrew/share/faust"):
        candidates.append(os.path.join(prefix, ARCH_REL_PATH))

    incomplete = []
    for path in candidates:
        if not os.path.isfile(path):
            continue
        arch_dir = os.path.dirname(os.path.abspath(path))
        missing = [name for name in SUPPORT_SOURCES
                   if not os.path.isfile(os.path.join(arch_dir, name))]
        if missing:
            incomplete.append((arch_dir, missing))
            continue
        return os.path.abspath(path), arch_dir

    if incomplete:
        arch_dir, missing = incomplete[0]
        die(
            f"{arch_dir} has clap-arch.cpp but is missing {', '.join(missing)}",
            "this is what a Faust installed before those files moved into",
            "architecture/clap looks like; reinstall Faust, or run faust2clap",
            "from a source checkout",
        )
    die(
        f"cannot find the CLAP architecture file ({ARCH_REL_PATH})",
        "check that Faust is installed and on PATH: faust --archdir",
        "or set FAUST_LIB to a Faust share directory",
    )


def find_clap_sdk(script_dir, override, arch_dir):
    """Locates the CLAP and clap-helpers include roots.

    Returns two include directories. Both projects ship their headers under
    `include/clap/`, and the trees do not overlap, so an installed Faust can
    carry them merged into one directory while a source checkout keeps them in
    two — both shapes are handled.

    Search order:

    1. `--clap-sdk DIR` or `CLAP_SDK_DIR`, honoured or refused, never silently
       replaced;
    2. the architecture directory, which is where `make install` puts the
       headers — this is what makes the tool work from a bare installation;
    3. `external/` in a source checkout, where the git submodules live.
    """
    def from_split_layout(root):
        """A checkout: external/clap-sdk/include and external/clap-helpers/include."""
        sdk = os.path.join(root, "clap-sdk", "include")
        helpers = os.path.join(root, "clap-helpers", "include")
        if not os.path.isfile(os.path.join(sdk, "clap", "clap.h")):
            return None
        if not os.path.isdir(os.path.join(helpers, "clap", "helpers")):
            die(
                f"found the CLAP SDK in {root} but not clap-helpers beside it",
                "run: git submodule update --init external/clap-helpers",
            )
        return os.path.abspath(sdk), os.path.abspath(helpers)

    def from_merged_layout(include_root):
        """An installation: one include root holding both trees."""
        if not os.path.isfile(os.path.join(include_root, "clap", "clap.h")):
            return None
        if not os.path.isdir(os.path.join(include_root, "clap", "helpers")):
            return None
        root = os.path.abspath(include_root)
        return root, root

    def look_in(root):
        return from_split_layout(root) or from_merged_layout(root)

    for source, root in (("--clap-sdk", override),
                         ("CLAP_SDK_DIR", os.environ.get("CLAP_SDK_DIR"))):
        if root:
            found = look_in(root)
            if found:
                return found
            die(f"{source} points at {root}, where no CLAP headers were found",
                "expected either clap-sdk/include + clap-helpers/include,",
                "or a single include root containing clap/clap.h and clap/helpers")

    for candidate in (os.path.join(arch_dir, "include"),
                      os.path.join(script_dir, "..", "..", "external")):
        found = look_in(candidate)
        if found:
            return found

    die(
        "cannot find the CLAP headers",
        "from a Faust checkout, run:",
        "  git submodule update --init external/clap-sdk external/clap-helpers",
        "from an installation, reinstall a Faust built with those submodules",
        "present, or point at the headers with --clap-sdk <dir>",
    )


# --- DSP inspection --------------------------------------------------------

def dsp_metadata(dsp_path):
    """Returns the `declare` metadata of the DSP.

    It comes from `faust -json`, the structured channel, so nothing has to be
    recovered by pattern-matching generated C++.

    Two things about `-json` are easy to get wrong, and the previous version of
    this tool got both:

    * it prints nothing on stdout — it writes `<dsp>.json`, so the result must
      be read back from disk;
    * neither `-o` nor the working directory relocates that file; it always
      lands beside the source.

    The file is removed afterwards unless the user already had one, so
    inspecting a DSP leaves no debris.
    """
    produced = dsp_path + ".json"
    pre_existing = os.path.exists(produced)
    try:
        subprocess.run(
            ["faust", "-json", dsp_path, "-o", os.devnull],
            check=True, capture_output=True, text=True,
        )
        with open(produced) as handle:
            parsed = json.load(handle)
        metadata = {}
        for entry in parsed.get("meta", []):
            metadata.update(entry)
        return metadata
    except (subprocess.CalledProcessError, OSError, json.JSONDecodeError):
        info("faust -json failed, falling back to parsing generated C++")
        return metadata_from_cpp(dsp_path)
    finally:
        if not pre_existing and os.path.exists(produced):
            os.remove(produced)


def metadata_from_cpp(dsp_path):
    """Last-resort metadata scrape from the generated C++."""
    try:
        cpp = subprocess.check_output(["faust", "-lang", "cpp", dsp_path], text=True)
    except subprocess.CalledProcessError:
        return {}
    metadata = {}
    for line in cpp.splitlines():
        match = re.search(r'm->declare\("([^"]+)",\s*"([^"]+)"\)', line)
        if match:
            metadata[match.group(1)] = match.group(2)
    return metadata


def resolve_nvoices(args, metadata):
    """Decides the voice count, following the faust2xx convention.

    The family treats polyphony as opt-in and `-nvoices` as the switch that
    requests it, so the order is:

    1. `-nvoices N` on the command line;
    2. otherwise `declare nvoices "N";` in the DSP itself;
    3. otherwise monophonic.

    Returns 0 for a monophonic plugin.

    There is deliberately no way to force mono from the command line. None of
    the 46 sibling tools that handle `-nvoices` offers one, and a
    `declare nvoices` is the DSP author stating what the program is; a caller
    who really wants otherwise edits that line.

    This also replaces an earlier heuristic that inferred polyphony from the
    audio input count. Guessing produced plugins whose voice behaviour nobody
    asked for.
    """
    if args.nvoices is not None:
        if args.nvoices < 1:
            die(f"-nvoices must be at least 1, got {args.nvoices}")
        info(f"polyphonic, {args.nvoices} voice(s) requested on the command line")
        return args.nvoices

    declared = metadata.get("nvoices")
    if declared is not None:
        try:
            count = int(str(declared).strip())
        except ValueError:
            die(f'the DSP declares nvoices "{declared}", which is not an integer')
        if count < 1:
            die(f"the DSP declares nvoices {count}, which must be at least 1")
        info(f"polyphonic, {count} voice(s) from the DSP's `declare nvoices`")
        return count

    return 0


# --- generation ------------------------------------------------------------

CMAKE_TEMPLATE = """\
# Generated by faust2clap for {plugin_name}. Edits are lost on regeneration.
cmake_minimum_required(VERSION 3.15)

# Both macOS settings must precede project(): it is project() that seeds the
# cache with their defaults, and a plain `set(... CACHE ...)` afterwards is a
# silent no-op. Guarded on CMAKE_HOST_APPLE rather than APPLE, which project()
# is what defines. `-D...` on the command line still wins, since a cache entry
# given there already exists by the time these run.
if (CMAKE_HOST_APPLE)
  set(CMAKE_OSX_DEPLOYMENT_TARGET "10.13" CACHE STRING "macOS target")
  # Build both slices. A host chooses the architecture it runs as, and an
  # arm64-only plugin is invisible to a DAW launched under Rosetta: it scans
  # x86_64 plugins only, into a separate cache, and never reports a problem.
  set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "macOS architectures")
endif()

project({plugin_name} LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)

add_library({plugin_name} MODULE
  "{generated_cpp}"
  "{plugin_impl}"
  "{gui_glue}"
)

# The CLAP SDK and clap-helpers are INTERFACE libraries carrying nothing but
# include directories, so they are consumed as include paths rather than
# through add_subdirectory. That keeps this file free of the SDK's own build
# system, and lets the headers come from an installed Faust as easily as from
# a source checkout.
target_include_directories({plugin_name} PRIVATE
  "${{CMAKE_CURRENT_SOURCE_DIR}}"
  "{faust_include}"
  "{faust_arch}"
  "{clap_include}"
  "{clap_helpers_include}"
)

if (APPLE)
  set_target_properties({plugin_name} PROPERTIES
    BUNDLE TRUE
    BUNDLE_EXTENSION clap
    OUTPUT_NAME {plugin_name}
    MACOSX_BUNDLE_GUI_IDENTIFIER {plugin_id}
    MACOSX_BUNDLE_BUNDLE_NAME {plugin_name}
    MACOSX_BUNDLE_BUNDLE_VERSION "{plugin_version}"
    MACOSX_BUNDLE_SHORT_VERSION_STRING "{plugin_version}"
    MACOSX_BUNDLE_INFO_PLIST "${{CMAKE_CURRENT_SOURCE_DIR}}/Info.plist.in"
  )
else()
  set_target_properties({plugin_name} PROPERTIES
    PREFIX "" OUTPUT_NAME {plugin_name} SUFFIX ".clap"
  )
endif()
"""


# A CLAP plugin is a loadable bundle, not an application. CMake's default
# MacOSXBundleInfo.plist.in declares CFBundlePackageType APPL, and a host that
# validates the bundle before loading it — REAPER does — then refuses the
# plugin. Only BNDL is correct here, and CMake exposes no variable for it, so
# the template has to be supplied.
BUNDLE_PLIST_TEMPLATE = """\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>CFBundleDevelopmentRegion</key>
\t<string>English</string>
\t<key>CFBundleExecutable</key>
\t<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
\t<key>CFBundleIdentifier</key>
\t<string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
\t<key>CFBundleInfoDictionaryVersion</key>
\t<string>6.0</string>
\t<key>CFBundleName</key>
\t<string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
\t<key>CFBundlePackageType</key>
\t<string>BNDL</string>
\t<key>CFBundleShortVersionString</key>
\t<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
\t<key>CFBundleVersion</key>
\t<string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
\t<key>CFBundleSignature</key>
\t<string>????</string>
\t<key>NSHighResolutionCapable</key>
\t<true/>
</dict>
</plist>
"""


def write_cmakelists(out_dir, **fields):
    with open(os.path.join(out_dir, "Info.plist.in"), "w") as handle:
        handle.write(BUNDLE_PLIST_TEMPLATE)
    path = os.path.join(out_dir, "CMakeLists.txt")
    with open(path, "w") as handle:
        handle.write(CMAKE_TEMPLATE.format(**fields))
    return path


def write_metadata_header(out_dir, plugin, nvoices, polyphonic):
    path = os.path.join(out_dir, "plugin_metadata.h")
    with open(path, "w") as handle:
        handle.write("// Generated by faust2clap. Edits are lost on regeneration.\n")
        handle.write("#pragma once\n")
        handle.write(f'#define FAUST_PLUGIN_ID "{plugin["id"]}"\n')
        handle.write(f'#define FAUST_PLUGIN_NAME "{plugin["name"]}"\n')
        handle.write(f'#define FAUST_PLUGIN_VENDOR "{plugin["vendor"]}"\n')
        handle.write(f'#define FAUST_PLUGIN_VERSION "{plugin["version"]}"\n')
        handle.write(f'#define FAUST_PLUGIN_DESCRIPTION "{plugin["description"]}"\n')
        handle.write(f"#define FAUST_NVOICES {nvoices}\n")
        handle.write(f"#define FAUST_IS_POLYPHONIC {1 if polyphonic else 0}\n")
    return path


# --- installation ----------------------------------------------------------

def user_clap_dir():
    """Where to drop a freshly built plugin so a host will find it.

    The locations come from the CLAP specification (`clap/entry.h`), which
    lists a system-wide and a per-user directory per platform. This tool
    installs without elevation, so it always picks the per-user one — on
    Windows that is `%LOCALAPPDATA%\Programs\Common\CLAP`, not
    `%COMMONPROGRAMFILES%\CLAP`, which needs administrator rights.

    `CLAP_PATH` wins when set: the specification requires hosts to search it,
    so a user who has pointed their hosts elsewhere expects builds to land
    there. Only its first entry is used — installing into all of them would
    scatter copies a user never asked for.
    """
    clap_path = os.environ.get("CLAP_PATH")
    if clap_path:
        first = clap_path.split(os.pathsep)[0].strip()
        if first:
            return os.path.expanduser(os.path.expandvars(first))

    if sys.platform == "darwin":
        return os.path.expanduser("~/Library/Audio/Plug-Ins/CLAP")
    if sys.platform.startswith("win"):
        return os.path.expandvars(r"%LOCALAPPDATA%\Programs\Common\CLAP")
    return os.path.expanduser("~/.clap")


def install_plugin(built, destination_dir):
    os.makedirs(destination_dir, exist_ok=True)
    destination = os.path.join(destination_dir, os.path.basename(built))
    if os.path.isdir(built):
        shutil.rmtree(destination, ignore_errors=True)
        shutil.copytree(built, destination)
    else:
        shutil.copy2(built, destination)
    return destination


def find_built_plugin(build_dir, plugin_name):
    for root, dirs, files in os.walk(build_dir):
        for name in dirs + files:
            if name == f"{plugin_name}.clap":
                return os.path.join(root, name)
    return None


# --- main ------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        prog="faust2clap",
        description="Compile a Faust DSP program into a CLAP plugin.",
    )
    parser.add_argument("-help", action="help", help=argparse.SUPPRESS)
    parser.add_argument("dsp_file", help="input .dsp file")
    parser.add_argument("-nvoices", type=int, metavar="N",
                        help="build a polyphonic instrument with N voices "
                             "(instruments only); without it the DSP's own "
                             "`declare nvoices` is used, else the plugin is "
                             "monophonic")
    parser.add_argument("-midi", action="store_true",
                        help="accepted for consistency with the other faust2xx "
                             "tools; CLAP plugins always receive host MIDI")
    parser.add_argument("--clap-sdk", metavar="DIR",
                        help="directory containing clap-sdk/ and clap-helpers/")
    parser.add_argument("--no-install", action="store_true",
                        help="build only, do not copy to the user CLAP directory")
    parser.epilog = (
        "Unrecognised options are passed through to the Faust compiler, "
        "so `-vec -lv 0 -I /path/to/lib` work as they do for the other "
        "faust2xx tools."
    )
    args, faust_options = parser.parse_known_args()

    # Refused rather than ignored: the CLAP architecture builds no OSC or
    # soundfile UI, so accepting these would produce a plugin silently missing
    # what the user asked for.
    for unsupported, reason in (
        ("-osc", "the CLAP architecture builds no OSC interface"),
        ("-soundfile", "the CLAP architecture builds no soundfile interface"),
    ):
        if unsupported in faust_options:
            die(f"{unsupported} is not supported by faust2clap: {reason}")


    dsp_path = os.path.abspath(args.dsp_file)
    if not os.path.isfile(dsp_path):
        die(f"no such DSP file: {args.dsp_file}")
    if shutil.which("faust") is None:
        die("faust is not on PATH")
    if shutil.which("cmake") is None:
        die("cmake is not on PATH")

    script_dir = os.path.dirname(os.path.abspath(__file__))
    arch_path, arch_dir = find_arch_file(script_dir)
    clap_include, clap_helpers_include = find_clap_sdk(script_dir, args.clap_sdk, arch_dir)

    base = os.path.splitext(os.path.basename(dsp_path))[0]
    # Build products live beside the .dsp, as in every sibling tool, but under
    # "<name>-clap" rather than "<name>".
    #
    # "<name>" is what faust2caqt, faust2jaqt and friends name the executable
    # they build from the same .dsp, so a user who has run one of those already
    # has a file sitting exactly there. faust2juce and faust2dplug resolve that
    # by "rm -rf"-ing whatever they find, which silently destroys the binary;
    # a suffixed directory cannot collide in the first place.
    out_dir = os.path.join(os.path.dirname(dsp_path), base + "-clap")
    if os.path.exists(out_dir) and not os.path.isdir(out_dir):
        die(f"{out_dir} exists and is not a directory",
            "faust2clap needs that name for its build directory;",
            "move or rename the file, or build the DSP from another directory")
    try:
        os.makedirs(out_dir, exist_ok=True)
    except OSError as error:
        die(f"cannot create {out_dir}: {error}")
    generated_cpp = os.path.join(out_dir, f"{base}_clap.cpp")

    metadata = dsp_metadata(dsp_path)
    nvoices = resolve_nvoices(args, metadata)
    polyphonic = nvoices > 0

    plugin = {
        "id": f"org.faust.{base.lower()}",
        "name": metadata.get("name", base),
        "vendor": metadata.get("author", "faust"),
        "version": metadata.get("version", "1.0.0"),
        "description": metadata.get("description", f"Generated from {base}.dsp"),
    }

    # -uim emits FAUST_INPUTS/FAUST_OUTPUTS alongside the class, which is how
    # the architecture file knows at compile time whether it is wrapping an
    # instrument or an effect. Without it the arity is only reachable through
    # getNumInputs(), a virtual member, and the plugin cannot describe itself
    # honestly to a host.
    try:
        subprocess.run(
            ["faust", "-a", arch_path, "-uim", *faust_options, dsp_path, "-o", generated_cpp],
            check=True,
        )
    except subprocess.CalledProcessError:
        die(f"faust failed to compile {os.path.basename(dsp_path)}")

    write_metadata_header(out_dir, plugin, max(nvoices, 1), polyphonic)
    write_cmakelists(
        out_dir,
        plugin_name=base,
        plugin_id=plugin["id"],
        plugin_version=plugin["version"],
        generated_cpp=generated_cpp,
        plugin_impl=os.path.join(arch_dir, "clap-plugin-impl.cpp"),
        gui_glue=os.path.join(arch_dir, "clap-gui-glue.cpp"),
        clap_include=clap_include,
        clap_helpers_include=clap_helpers_include,
        faust_include=faust_dir("--includedir") or "/usr/local/include",
        faust_arch=faust_dir("--archdir") or "/usr/local/share/faust",
    )

    build_dir = os.path.join(out_dir, "build")
    for stage, command in (
        ("configure", ["cmake", "-S", out_dir, "-B", build_dir]),
        ("build", ["cmake", "--build", build_dir]),
    ):
        result = subprocess.run(command, capture_output=True, text=True)
        if result.returncode != 0:
            print(result.stdout, file=sys.stderr)
            print(result.stderr, file=sys.stderr)
            die(f"cmake {stage} failed for {base}")

    built = find_built_plugin(build_dir, base)
    if built is None:
        die(f"cmake reported success but no {base}.clap was produced",
            f"look in {build_dir}")

    info(f"built {built}")
    if not args.no_install:
        info(f"installed {install_plugin(built, user_clap_dir())}")


if __name__ == "__main__":
    main()
