"""Command handlers for:

eda pnr --tool=openroad TARGET
"""

import os
import shutil
from pathlib import Path

from opencos import util
from opencos.commands.pnr import CommandPnr
from opencos.eda_base import Tool
from opencos.files import get_source_files_paths
from opencos.tools.slang_yosys import CommandFListSlangYosys
from opencos.utils import docker_checks
from opencos.utils.docker_checks import docker_ok


class ToolOpenRoad(Tool):
    """Tool class for: eda COMMAND --tool=openroad [args] TARGETS"""

    _TOOL = "openroad"
    _EXE = (
        ""  # This is OK to leave empty str, will use config.tools.TOOL.requires-docker
    )
    _URL = "https://theopenroadproject.org/"

    DEFAULT_DOCKER_IMAGE = "ghcr.io/cognichip/openroad-flow:latest"

    def get_versions(self, **kwargs) -> str:
        if self._VERSION:
            return self._VERSION

        if not docker_ok():
            # docker is required for this tool.
            self.error(
                f'For --tool={self._TOOL}, you must have functioning "docker"',
                "see --help for args --docker-run and --docker-image",
            )
            self._VERSION = "UNKNOWN"
            return self._VERSION

        self.get_set_default_docker_image()

        if not docker_checks.check_image_available(image=self.DEFAULT_DOCKER_IMAGE):
            # Be nice to eda --help, don't call docker run to get versioning if
            # the image hasn't been pulled.
            self._VERSION = "IMAGE-NOT-PULLED"
            return self._VERSION

        # this is a little customized, b/c we don't really have an Open Road
        # "version", all we have is a git tag or git hash
        git_info_str = (
            "cd /tools/OpenROAD-flow-scripts && echo "
            "$(git log -1 --format=%cs) "
            "$(git rev-parse --abbrev-ref HEAD) "
            "$(git rev-parse --short HEAD)"
        )
        env_dict = docker_checks.get_docker_env(cmd_des_obj=self)
        version_ret = docker_checks.run_docker_for_version_info(
            image=self.DEFAULT_DOCKER_IMAGE,
            env_dict=env_dict,
            commands=["-c", git_info_str],
        )
        if version_ret:
            self._VERSION = ".".join(version_ret.strip().split()).replace("..", ".")
        else:
            self._VERSION = "UNKNOWN"

        return self._VERSION


class CommandPnrOpenRoad(CommandPnr, ToolOpenRoad):
    """Command handler for: eda pnr --tool=openroad"""

    env_args_map: dict = {
        "LIBERTY_FILE": "liberty-file",
        "LEF_TECH_FILE": "lef-tech-file",
        "LEF_FILE": "lef-file",
        "PIPE_STAGES": "pipe-stages",
        "RUN_OPENROAD": "run-openroad",
        "SYNTH_MODE": "synth-mode",
        "DELAY_TARGET_PS": "delay-target-ps",
        "SKIP_PASS2": "skip-pass2",
        # These are special, if the arg value is blank we'll auto fill it in from
        # parent classes Command/CommandDeisgn:
        "OUT_DIR": "work-dir",
        "TOP_MODULE": "top",
        "FILE_SUFFIX": "file-suffix",
    }

    sh_and_tcl_to_copy_to_work_dir: list = [
        "run_eda.sh",
        "syn_basic.tcl",
        "synth_pass1.tcl",
        "synth_pass2_targeted.tcl",
        "synth_pass2_full.tcl",
        "sta_analyze.tcl",
        "pnr_basic.tcl",
        "pnr_flow.tcl",
        "pdk_config.tcl",
        "ppa_metrics.tcl",
        "sta.tcl",
        "process_timing_metrics.py",
        "clock_ident.py",
    ]

    run_eda_sh__leaf = "run_eda.sh"
    run_eda_sh__fpath = str(
        (Path(os.path.dirname(__file__)) / "sh" / run_eda_sh__leaf).resolve()
    )

    def __init__(self, config: dict):
        CommandPnr.__init__(self, config=config)
        ToolOpenRoad.__init__(self, config=self.config)

        # Note: most of these have to work with the --docker-image, but these are easily overriden
        # with a .f file. These are also sent to the docker container via a generated env file, so
        # we also override these here if they are in your ENV.
        self.args.update({
            "docker-image": self.DEFAULT_DOCKER_IMAGE,
            "docker-env-keys": list(self.env_args_map.keys()),
            "liberty-file": "",
            "lef-tech-file": "",
            "lef-file": "",
            "pipe-stages": 1,
            "run-openroad": 1,
            "synth-mode": "twopass",
            "delay-target-ps": "",
            "skip-pass2": 0,
            "adaptive-clock": 0,
            "file-suffix": "",
            "clock-name": "",
            "clock-ns": "",
            "idelay-ns": "2",
            "odelay-ns": "2",
            "sta-sdc-file": "",
            "platform": "sky130hs",
            "stop-stage": "route",
            "place-density": "",
            "core-utilization": 65,
            "core-aspect-ratio": "1.0",
            "setup-slack-margin": 0,
            "hold-slack-margin": 0,
            "cts-buf-list": "",
            "max-wire-length": "",
            "run-eda-script": self.run_eda_sh__fpath,
        })

        self.args_help.update({
            "docker-image": "Docker image to use, required",
            "docker-env-keys": "environment variables that are passed to docker run command",
            "liberty-file": (
                "FILEPATH(s) to .lib (or .lib.gz) within the docker-image."
                " Space-delimited for multi-lib PDKs (e.g. ASAP7)."
                " If unset, auto-resolved from --platform."
            ),
            "lef-tech-file": (
                "FILEPATH to lef tech (tlef) file within the docker-image."
                " If unset, auto-resolved from --platform."
            ),
            "lef-file": (
                "FILEPATH(s) to lef file(s) within docker-image."
                " Space-delimited for multi-lef PDKs."
                " If unset, auto-resolved from --platform."
            ),
            "synth-mode": (
                "Synthesis mode: 'twopass' (default) for two-pass timing-driven synthesis,"
                " or 'basic' for the original single-pass flow."
            ),
            "delay-target-ps": (
                "ABC delay target in picoseconds for Pass 2 gate sizing."
                " If unset, auto-calculated as 85%% of clock period."
            ),
            "skip-pass2": (
                "Set to 1 to skip Pass 2 even if timing is violated after Pass 1."
                " Useful for quick area-only runs with the two-pass flow."
            ),
            "adaptive-clock": (
                "Set to 1 to enable adaptive clock period. When no --clock-ns is"
                " specified: Pass 1 uses a relaxed 10ns period, then tightens to"
                " 95%% of worst path delay after STA. Without this flag, unspecified"
                " clock-ns defaults to a PDK-appropriate period for a standard"
                " datapath (~15 logic levels). Has no effect when --clock-ns is set."
            ),
            "file-suffix": (
                "FILE_SUFFIX passed to docker run command, it is appeneded to the TOP_MODULE,"
                " if unset uses value from --seed."
            ),
            "platform": (
                "PDK platform identifier. Supported: sky130hs (default), asap7,"
                " sky130hd, nangate45, gf180, ihp-sg13g2. Controls floorplan site,"
                " routing layers, CTS buffers, PDN, tap cells, wire RC."
            ),
            "stop-stage": (
                "How far the PnR flow should progress: 'synth' (synthesis only),"
                " 'place' (through placement+timing repair), 'cts' (through clock tree),"
                " 'route' (full flow including detailed routing, default)."
            ),
            "place-density": (
                "Placement density override (0.0-1.0). If unset, uses the PDK default."
            ),
            "core-utilization": "Floorplan utilization target in percent (default: 65).",
            "core-aspect-ratio": "Floorplan aspect ratio (default: 1.0).",
            "setup-slack-margin": (
                "Setup timing repair margin in picoseconds (default: 0)."
            ),
            "hold-slack-margin": (
                "Hold timing repair margin in picoseconds (default: 0)."
            ),
            "cts-buf-list": (
                "CTS buffer cell list (space-separated). If unset, OpenROAD auto-detects."
            ),
            "max-wire-length": (
                "Max wire length in µm for repair_design. If unset, uses tool default."
            ),
            "run-eda-script": (
                "The default will use run_eda.sh from the pypackage opencos/tools/openroad/sh/,"
                " however you are free to bring-your-own script. If you have a local ./run_eda.sh"
                " that will be used instead of the pypackage one."
                ' This script is what is run via "docker run", volume mapped, in the container.'
            ),
        })

        self._update_args_with_current_env()

    def do_it(self) -> None:
        self.set_tool_defines()

        if not self.args["file-suffix"]:
            self.args["file-suffix"] = str(self.args.get("seed", "UNKNOWN"))

        self.write_eda_config_and_args()
        self._gen_docker_run_shell_command()

    def _set_run_eda_sh_script(self) -> None:
        """
        Finds string abspath of the args --run-eda-script=FILE or default value
        to use the one from the installed python package, and sets in
        self.run_eda_sh__fpath, self.run_eda_sh__leaf
        """
        try_file = Path(self.args["run-eda-script"])
        if try_file.is_file():
            self.run_eda_sh__fpath = str(try_file.resolve())
            self.run_eda_sh__leaf = str(try_file.name)
            return

        if try_file.parent == Path("."):
            # User provided a relative local file, or one w/out path, we can search in the
            # the pypackage space:
            try_installed = Path(os.path.dirname(__file__)) / "sh" / try_file.name
            if try_installed.is_file():
                self.run_eda_sh__fpath = str(try_installed.resolve())
                self.run_eda_sh__leaf = str(try_installed.name)
                return

            self.error(
                f"Cannot find file --run-eda-script={str(try_file)}, nor in",
                f"the installed defaults: {str(Path(os.path.dirname(__file__)) / 'sh')}",
            )
            return

        self.error(f"Cannot find file --run-eda-script={str(try_file)}")
        return

    def _update_args_with_current_env(self) -> None:
        """Called during __init__, updates self.args default values based on current ENV"""
        for env_name, arg_name in self.env_args_map.items():
            if value := os.environ.get(env_name, ""):
                # Note os.environ is str types, so check on bool/int/str
                cur_args_val = self.args[arg_name]
                if isinstance(cur_args_val, list):
                    # Don't know the type, so replace with a [str] and hope for the best
                    self.args[arg_name] = [value]
                elif isinstance(cur_args_val, int):
                    self.args[arg_name] = int(value)
                elif isinstance(cur_args_val, bool):
                    try:
                        self.args[arg_name] = bool(int(value))
                    except:  # pylint: disable=bare-except
                        self.args[arg_name] = bool(value)
                else:
                    self.args[arg_name] = value

    def _copy_scripts_to_work_dir(self) -> None:
        """Copies scripts to work dir"""
        # I guess we'll write out run_eda_sh__fpath to the work-dir, so user can re-run all
        # this via shell
        for x in self.sh_and_tcl_to_copy_to_work_dir:
            subdir = "."
            if x.endswith(".py"):
                pass
            elif x.endswith(".tcl"):
                subdir = "tcl"
            elif x.endswith(".sh"):
                subdir = "sh"
            else:
                self.error(
                    "unknow extension in CommandPnrOpenRoad.sh_and_tcl_to_copy_to_work_dir:",
                    f"{x}",
                )

            _fpath = str(Path(os.path.dirname(__file__)) / subdir / x)

            if not os.path.exists(_fpath):
                self.error(f"Cannot find required file in pypackage dist: {_fpath}")
            else:
                shutil.copy(_fpath, os.path.join(self.args["work-dir"], x))

        # Also copy self.run_eda_sh__fpath:
        shutil.copy(
            self.run_eda_sh__fpath,
            os.path.join(self.args["work-dir"], self.run_eda_sh__leaf),
        )

    def _gen_docker_run_shell_command(self) -> None:
        """Generates a docker_run.sh file (in work-dir) that will be used by: docker run"""

        work_dir_abs = Path(self.args["work-dir"]).resolve()
        self.args["work-dir"] = str(work_dir_abs)

        env_dict = {}
        for key in self.args["docker-env-keys"]:
            env_dict[key] = None
            if key in self.env_args_map:
                env_dict[key] = self.args[self.env_args_map[key]]

        # PDK library paths (LIBERTY_FILE, LEF_TECH_FILE, LEF_FILE) should be
        # resolved by run_eda.sh's resolve_pdk_libs() based on PLATFORM, unless
        # the user explicitly specified them via CLI args (--liberty-file, etc.).
        # Remove them from env_dict so that stale host environment variables
        # (e.g. from a previous sky130hs run) don't override the
        # platform-appropriate paths resolved by run_eda.sh.
        _pdk_lib_keys = ("LIBERTY_FILE", "LEF_TECH_FILE", "LEF_FILE")
        for _pk in _pdk_lib_keys:
            _arg_name = self.env_args_map.get(_pk, "")
            if not _arg_name:
                continue
            _val = self.args.get(_arg_name, "")
            _env_val = os.environ.get(_pk, "")
            # Keep the value only if the user explicitly set it on the CLI.
            # Heuristic: if the current value is empty OR matches the host env
            # var exactly, it wasn't set on the CLI — it's either the default
            # or came from _update_args_with_current_env.
            if not _val or _val == _env_val:
                env_dict.pop(_pk, None)

        # Instead of passing env vars (like VERILOG_FILES, ...) for files, incdirs,
        # defines, parameters to a docker container, instead
        # we will craft a .f for yosys or slang-plugin, using eda.
        # This should work even if the tool is not installed.

        flist_sco = CommandFListSlangYosys(config=self.config)
        flist_sco.set_tool_defines()
        for x in ("incdirs", "files_v", "files_sv", "parameters"):
            setattr(flist_sco, x, getattr(self, x))
        # for defines, we have to update with self.defines instead of copy, otherwise
        # you'll overwrite the tool defines from eda_config:
        getattr(flist_sco, "defines", {}).update(self.defines)

        # NOTE: this will not handle blackboxing as you might expect in DEPS.yml,
        # I'm not sure what to do about that yet, unless we wanted to support a user
        # BYO eda synth output(s). For now, still doing this one-shot
        yosys_flist_fpath = os.path.join(work_dir_abs, "eda_flist.yosys.read_slang.f")
        flist_sco.write_flist_to_file(fpath=yosys_flist_fpath)
        env_dict["YOSYS_READ_SLANG_DOTF"] = "eda_flist.yosys.read_slang.f"

        # send CLOCK_NAME, CLOCK_NS (period), STA_SDC_FILE, synthesis control,
        # PnR control, and PDK vars to the docker container
        for argname in (
            "clock-name",
            "clock-ns",
            "idelay-ns",
            "odelay-ns",
            "sta-sdc-file",
            "synth-mode",
            "delay-target-ps",
            "skip-pass2",
            "adaptive-clock",
            "platform",
            "stop-stage",
            "place-density",
            "core-utilization",
            "core-aspect-ratio",
            "setup-slack-margin",
            "hold-slack-margin",
            "cts-buf-list",
            "max-wire-length",
        ):
            if x := self.args[argname]:
                env_dict[argname.upper().replace("-", "_")] = x

        self._set_run_eda_sh_script()

        docker_run_base_cmd = docker_checks.get_docker_run_command(
            image=self.args["docker-image"],
            docker_command="run",
            user="$(id -u):$(id -g)",
            env_dict=env_dict,
            ro_volumes=get_source_files_paths(
                cmd_des_obj=self, minimal_root_paths=True
            ),
            rw_volumes=[work_dir_abs],
            threads=self.args["threads"],
        )

        self._copy_scripts_to_work_dir()

        util.write_shell_command_file(
            dirpath=self.args["work-dir"],
            filename="docker_run_edawrap_pnr.sh",
            command_lists=[
                util.ShellCommandList(
                    # Note the cd {work_dir_abs} is in the docker container:
                    docker_run_base_cmd
                    + [
                        "-c",
                        f"'cd {work_dir_abs} && ./{self.run_eda_sh__leaf}'",
                    ],
                    tee_fpath="docker_run.run_eda.log",
                )
            ],
            line_breaks=False,
        )

        # Promiscously save all .json that is saved in our work-dir by any TCL or SH scripts:
        util.artifacts.add_extension(
            search_paths=self.args["work-dir"], file_extension='json', typ='json',
            description='JSON data from PNR'
        )

        self.exec(
            work_dir=self.args["work-dir"], command_list=["./docker_run_edawrap_pnr.sh"]
        )
