#!/usr/bin/env python3

import json
import os
import shlex
import shutil
import subprocess
import sys

from helpers.gitea import GiteaCommand
from helpers.renderdoc import RenderDocCommand
from helpers.shaders import ShadersCommand
from helpers.tracy import TracyCommand

COMMAND_TO_METHOD = {
    "branch-from": "branch_from",
    "bash-completions": "bash_completions",
}
METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()}

WAIT_FOR_PROCESS = "wait_for_process"

class DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand):
    PROJECT_NAME = "sophia"
    DEFAULT_PRESET = "debug"

    def __init__(self):
        self.last_exit_status = 0

    # Core logic
    def call(self, *args, **kwargs):
        if not args:
            print("Usage: dev <command> [args...]\n\nCommands:")
            for command in self._commands():
                print(f"  {command}")
            return

        command, rest = args[0], args[1:]
        method_name = COMMAND_TO_METHOD.get(command, command)
        method = getattr(self, method_name, None)
        if callable(method) and not method_name.startswith("_") and method_name != "call":
            return method(*rest, **kwargs)

    # Primary command wrappers

    ##
    # Configures and builds a CMake preset (see CMakePresets.json).
    #   dev build [preset] [--fresh]
    # The preset defaults to debug and builds into build/<preset>. --fresh
    # discards the preset's CMake cache before configuring.
    def build(self, *args, **kwargs):
        preset, fresh = self._parse_preset_args(args, "build")
        self._build_preset(preset, fresh)

    ##
    # Builds the debug preset (incrementally), then runs the editor from
    # build/debug. Any arguments are passed through to the editor.
    #   dev editor [args...]
    def editor(self, *args, **kwargs):
        preset = self.DEFAULT_PRESET
        self._build_preset(preset, fresh=False)

        preset_dir = os.path.join(self._project_root(), "build", preset)
        binary = os.path.join(preset_dir, "bin", "editor")
        print(f"Running: {shlex.join([binary, *args])}")
        os.chdir(preset_dir)
        os.execv(binary, [binary, *args])

    ##
    # Builds the debug preset (incrementally), then runs the testbed from
    # build/debug. Any arguments are passed through to the testbed.
    #   dev testbed [args...]
    def testbed(self, *args, **kwargs):
        preset = self.DEFAULT_PRESET
        self._build_preset(preset, fresh=False)

        preset_dir = os.path.join(self._project_root(), "build", preset)
        binary = os.path.join(preset_dir, "bin", "testbed")
        print(f"Running: {shlex.join([binary, *args])}")
        os.chdir(preset_dir)
        os.execv(binary, [binary, *args])

    ##
    # Builds the debug preset (incrementally), then runs the raw Vulkan
    # sandbox from build/debug. Any arguments are passed through.
    #   dev sandbox [args...]
    def sandbox(self, *args, **kwargs):
        preset = self.DEFAULT_PRESET
        self._build_preset(preset, fresh=False)

        preset_dir = os.path.join(self._project_root(), "build", preset)
        binary = os.path.join(preset_dir, "bin", "sandbox")
        print(f"Running: {shlex.join([binary, *args])}")
        os.chdir(preset_dir)
        os.execv(binary, [binary, *args])

    # Custom helpers

    def ownit(self, *args, **kwargs):
        file_or_directory = args[0] if args else None
        if file_or_directory is None:
            raise ValueError("Must provide a file or directory path.")

        if sys.platform.startswith("linux"):
            print(f"Take ownership of the file or directory? {file_or_directory}")
            command = f"sudo chown -R {self._user_id()}:{self._group_id()} {file_or_directory}"
            self._exec(command, replace=True)
        else:
            raise NotImplementedError(f"Not implemented for platform {sys.platform}")

    def bash_completions(self, *args, **kwargs):
        print(" ".join(self._commands()))

    # Private helpers
    def _commands(self):
        return sorted(
            METHOD_TO_COMMAND.get(name, name)
            for name in dir(DevCommand)
            if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name))
        )

    def _parse_preset_args(self, args, command):
        preset = None
        fresh = False
        for arg in args:
            if arg == "--fresh":
                fresh = True
            elif arg.startswith("-"):
                raise ValueError(f"Unknown option: {arg}")
            elif preset is None:
                preset = arg
            else:
                raise ValueError(f"Usage: dev {command} [preset] [--fresh]")

        preset = preset or self.DEFAULT_PRESET
        presets = self._configure_presets()
        if preset not in presets:
            raise ValueError(f"Unknown preset: {preset} (available: {', '.join(presets)})")
        return preset, fresh

    def _configure_presets(self):
        with open(os.path.join(self._project_root(), "CMakePresets.json")) as f:
            presets = json.load(f)["configurePresets"]
        return [preset["name"] for preset in presets if not preset.get("hidden")]

    def _build_preset(self, preset, fresh):
        configure = ["cmake", "--preset", preset]
        if fresh:
            configure.append("--fresh")
        self._run_in_root(configure)
        self._copy_compile_commands(preset)
        self._run_in_root(["cmake", "--build", "--preset", preset])

    # Editor tooling reads build/compile_commands.json, so point it at the
    # most recently configured preset.
    def _copy_compile_commands(self, preset):
        build_dir = os.path.join(self._project_root(), "build")
        source = os.path.join(build_dir, preset, "compile_commands.json")
        if os.path.isfile(source):
            shutil.copyfile(source, os.path.join(build_dir, "compile_commands.json"))

    def _run_in_root(self, command):
        print(f"Running: {shlex.join(command)}")
        result = subprocess.run(command, cwd=self._project_root())
        if result.returncode != 0:
            sys.exit(result.returncode)

    def _wait_for_process_with_logging(self, command):
        process = self._exec(command, capture=True)
        for line in process.stdout:
            print(line, end="")
        process.wait()
        self.last_exit_status = process.returncode

    def _exec_in_root(self, command, **kwargs):
        return self._exec(f"cd {self._project_root()} && {command}", **kwargs)

    def _exec(self, command, *, replace=False, capture=False):
        print(f"Running: {command}")
        if replace:
            os.execv("/bin/sh", ["/bin/sh", "-c", command])
        if capture:
            return subprocess.Popen(
                ["/bin/sh", "-c", f"{command} 2>&1"],
                stdout=subprocess.PIPE,
                text=True,
            )
        return subprocess.run(["/bin/sh", "-c", command])

    def _project_root(self):
        return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))

    def _take_over_needed(self, file_or_directory):
        result = subprocess.run(
            ["/bin/sh", "-c", f"find {file_or_directory} -not -user {self._user_id()} -print -quit | grep -q ."],
        )
        return result.returncode == 0

    def _user_id(self):
        if not sys.platform.startswith("linux"):
            raise NotImplementedError(f"Not implemented for platform {sys.platform}")
        return subprocess.run(["id", "-u"], stdout=subprocess.PIPE, text=True).stdout.strip()

    def _group_id(self):
        if not sys.platform.startswith("linux"):
            raise NotImplementedError(f"Not implemented for platform {sys.platform}")
        return subprocess.run(["id", "-g"], stdout=subprocess.PIPE, text=True).stdout.strip()


if __name__ == "__main__":
    DevCommand().call(*sys.argv[1:])
