"""CLI entry point for plushie: ``python -m plushie <command>``.

Provides commands matching the Elixir mix tasks:

- ``run`` -- resolve binary, spawn renderer, start runtime, block
- ``connect`` -- stdio transport mode (for ``plushie --exec``)
- ``download`` -- fetch precompiled binary
- ``build`` -- build custom binary with extensions
- ``inspect`` -- init app, call view, normalize, print as JSON
- ``script`` -- run ``.plushie`` test scripts
- ``replay`` -- replay a script with real windows
"""

from __future__ import annotations

import argparse
import importlib
import json
import logging
import sys
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from plushie.extension import ExtensionDef

from plushie.app import App

logger = logging.getLogger("plushie")


def _import_app(spec: str) -> type[App[Any]]:
    """Import an App class from a ``module:Class`` specifier.

    Args:
        spec: A string like ``"mypackage.mymodule:MyApp"`` where the
            part before the colon is a dotted module path and the part
            after is the class name.

    Returns:
        The App subclass.

    Raises:
        SystemExit: If the specifier is malformed, the module cannot be
            imported, or the attribute is not an App subclass.
    """
    if ":" not in spec:
        print(
            f"error: invalid app specifier {spec!r} -- expected module:Class",
            file=sys.stderr,
        )
        raise SystemExit(1)

    module_path, class_name = spec.rsplit(":", 1)

    try:
        module = importlib.import_module(module_path)
    except ImportError as exc:
        print(f"error: cannot import module {module_path!r}: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    cls = getattr(module, class_name, None)
    if cls is None:
        print(
            f"error: module {module_path!r} has no attribute {class_name!r}",
            file=sys.stderr,
        )
        raise SystemExit(1)

    if not (isinstance(cls, type) and issubclass(cls, App)):
        print(
            f"error: {spec!r} is not an App subclass",
            file=sys.stderr,
        )
        raise SystemExit(1)

    return cls


# ---------------------------------------------------------------------------
# pyproject.toml config
# ---------------------------------------------------------------------------


def _load_pyproject_config(project_dir: str | Path | None = None) -> dict[str, Any]:
    """Load ``[tool.plushie]`` from pyproject.toml if present.

    Looks for ``pyproject.toml`` in *project_dir* (defaults to cwd).
    Returns the ``[tool.plushie]`` table as a dict, or an empty dict
    if the file is missing or the section doesn't exist.
    """
    root = Path(project_dir) if project_dir else Path.cwd()
    toml_path = root / "pyproject.toml"
    if not toml_path.is_file():
        return {}
    try:
        with open(toml_path, "rb") as f:
            data = tomllib.load(f)
    except (OSError, tomllib.TOMLDecodeError):
        return {}
    return data.get("tool", {}).get("plushie", {})


# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------


def _cmd_run(args: argparse.Namespace) -> None:
    """Handle the ``run`` command."""
    import plushie

    app_class = _import_app(args.app)

    mode = args.mode
    use_json = args.json
    watch = args.watch

    conn_opts: dict[str, Any] = {}
    if use_json:
        conn_opts["json"] = True

    daemon = getattr(args, "daemon", False)

    if watch:
        from plushie.dev_server import DevServer

        dev = DevServer(args.app, mode=mode, **conn_opts)
        dev.run()
    else:
        plushie.run(app_class, mode=mode, daemon=daemon, **conn_opts)


def _cmd_connect(args: argparse.Namespace) -> None:
    """Handle the ``connect`` command (stdio transport)."""
    from plushie.connection import StdioConnection
    from plushie.runtime import Runtime

    app_class = _import_app(args.app)
    app = app_class()

    with StdioConnection() as conn:
        # StdioConnection doesn't have the same interface as Connection
        # but Runtime expects Connection. We use duck typing here since
        # both implement the same send/receive interface.
        runtime = Runtime(app, conn)  # type: ignore[arg-type]
        runtime.run()


def _resolve_artifacts(
    args: argparse.Namespace,
    pyproject_cfg: dict[str, Any],
) -> tuple[bool, bool]:
    """Determine which artifacts to process (bin, wasm).

    Resolution order: CLI flags > pyproject.toml ``artifacts`` > default ``["bin"]``.

    Returns:
        Tuple of ``(want_bin, want_wasm)``.
    """
    cli_bin = getattr(args, "bin", False)
    cli_wasm = getattr(args, "wasm", False)

    # If any CLI flag is set, use CLI flags exclusively
    if cli_bin or cli_wasm:
        return cli_bin, cli_wasm

    # Fall back to pyproject.toml artifacts config
    artifacts = pyproject_cfg.get("artifacts")
    if artifacts is not None:
        return "bin" in artifacts, "wasm" in artifacts

    # Default: bin only
    return True, False


def _cmd_download(args: argparse.Namespace) -> None:
    """Handle the ``download`` command."""
    version = args.version
    force = args.force

    pyproject_cfg = _load_pyproject_config()
    want_bin, want_wasm = _resolve_artifacts(args, pyproject_cfg)

    # Resolve bin_file: CLI flag > pyproject.toml > None (standard location)
    bin_file = getattr(args, "bin_file", None) or pyproject_cfg.get("bin_file")

    # Resolve wasm_dir: CLI flag > pyproject.toml > None (standard location)
    wasm_dir_override = getattr(args, "wasm_dir", None) or pyproject_cfg.get("wasm_dir")

    if want_bin:
        from plushie.binary import download

        path = download(version=version, force=force, bin_file=bin_file)
        print(f"downloaded: {path}")

    if want_wasm:
        from plushie.binary import download_wasm

        path = download_wasm(
            version=version, force=force, wasm_dir_path=wasm_dir_override
        )
        print(f"downloaded WASM bundle: {path}")


def _parse_extensions(raw: list[dict[str, Any]]) -> list[ExtensionDef]:
    """Parse a list of raw extension dicts into ``ExtensionDef`` objects.

    Works for both JSON config files and pyproject.toml ``[tool.plushie]``
    extension entries.
    """
    from plushie.extension import CommandDef, ExtensionDef, ParamDef, PropDef

    extensions: list[ExtensionDef] = []
    for ext_data in raw:
        props = [PropDef(p["name"], p["prop_type"]) for p in ext_data.get("props", [])]
        commands = [
            CommandDef(
                c["name"],
                [ParamDef(pm["name"], pm["param_type"]) for pm in c.get("params", [])],
            )
            for c in ext_data.get("commands", [])
        ]
        extensions.append(
            ExtensionDef(
                kind=ext_data["kind"],
                rust_crate=ext_data["rust_crate"],
                rust_constructor=ext_data["rust_constructor"],
                props=props,
                commands=commands,
            )
        )
    return extensions


def _cmd_build(args: argparse.Namespace) -> None:
    """Handle the ``build`` command."""
    import os
    import shutil
    import stat
    import subprocess

    from plushie.binary import check_rust_version

    release = args.release

    # Load pyproject.toml config (used for source_path, build_name,
    # extensions, artifacts, and path overrides).
    pyproject_cfg = _load_pyproject_config()

    want_bin, want_wasm = _resolve_artifacts(args, pyproject_cfg)

    if want_wasm:
        from plushie.binary import build_wasm

        source = os.environ.get(
            "PLUSHIE_SOURCE_PATH",
            pyproject_cfg.get("source_path"),
        )
        wasm_dir_override = pyproject_cfg.get("wasm_dir")
        path = build_wasm(
            source_path=source, release=release, wasm_dir_path=wasm_dir_override
        )
        print(f"built WASM renderer: {path}")

    if not want_bin:
        return

    check_rust_version()

    from plushie.extension import generate_cargo_toml, generate_main_rs

    # Extension resolution order:
    # 1. pyproject.toml [tool.plushie] extensions
    # 2. JSON config file (--config flag or plushie_extensions.json)
    # 3. Stock build (no extensions)
    extensions: list[ExtensionDef] = []

    pyproject_extensions = pyproject_cfg.get("extensions", [])
    if pyproject_extensions:
        extensions = _parse_extensions(pyproject_extensions)
    else:
        config_path = args.config or "plushie_extensions.json"
        if os.path.isfile(config_path):
            with open(config_path) as f:
                data = json.load(f)
            extensions = _parse_extensions(data.get("extensions", []))

    # source_path resolution: env var > pyproject.toml
    source = os.environ.get(
        "PLUSHIE_SOURCE_PATH",
        pyproject_cfg.get("source_path"),
    )

    if not extensions:
        # Stock build (no extensions) -- build vanilla binary from source
        if source is None:
            config_path = args.config or "plushie_extensions.json"
            print("no extensions found and PLUSHIE_SOURCE_PATH not set")
            print(f"  looked for extension config at: {config_path}")
            print("  looked for [tool.plushie] in pyproject.toml")
            print("")
            print("to build with extensions:")
            print("  add [tool.plushie] extensions to pyproject.toml")
            print(f"  or create {config_path} with extension definitions")
            print("")
            print("to build the stock binary from source:")
            print("  export PLUSHIE_SOURCE_PATH=/path/to/plushie")
            print("  or set source_path in [tool.plushie]")
            raise SystemExit(1)

        # Build stock binary from source
        plushie_crate = os.path.join(source, "plushie-renderer")
        if not os.path.isdir(plushie_crate):
            print(
                f"plushie-renderer crate not found at {plushie_crate}", file=sys.stderr
            )
            raise SystemExit(1)

        cargo_args = ["cargo", "build"]
        if release:
            cargo_args.append("--release")
        profile = "release" if release else "debug"
        verbose = getattr(args, "verbose", False)

        print(f"building stock binary{' (release)' if release else ''}...")
        result = subprocess.run(
            cargo_args,
            cwd=plushie_crate,
            capture_output=not verbose,
            check=False,
        )
        if result.returncode != 0:
            if not verbose and result.stderr:
                sys.stderr.buffer.write(result.stderr)
            print("build failed", file=sys.stderr)
            raise SystemExit(result.returncode)

        # Install to standard download location
        from plushie.binary import download_dir, download_name

        bin_ext = ".exe" if sys.platform in ("win32", "cygwin") else ""
        built = os.path.join(source, "target", profile, f"plushie-renderer{bin_ext}")
        if not os.path.isfile(built):
            print(
                f"build succeeded but binary not found at {built}",
                file=sys.stderr,
            )
            raise SystemExit(1)
        dest_dir = download_dir()
        dest_dir.mkdir(parents=True, exist_ok=True)
        dest = dest_dir / download_name()
        shutil.copy2(built, str(dest))
        dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
        print(f"installed: {dest}")

        # Also install to bin_file if configured in pyproject.toml
        bin_file = pyproject_cfg.get("bin_file")
        if bin_file:
            bin_path = Path(bin_file)
            bin_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(built, str(bin_path))
            bin_path.chmod(bin_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
            print(f"installed: {bin_path}")
        return

    # Validate extensions
    from plushie.extension import validate_all

    errors = validate_all(extensions)
    if errors:
        print("extension validation failed:", file=sys.stderr)
        for err in errors:
            print(f"  - {err}", file=sys.stderr)
        raise SystemExit(1)

    # binary_name resolution: --name flag > pyproject.toml build_name > default
    binary_name = args.name or pyproject_cfg.get("build_name") or "plushie-custom"

    # Generate build files -- workspace at _plushie_build/ in the project root.
    # Must be at the project root level so extension crates (e.g. native/gauge/)
    # are hierarchically below the workspace root, which Cargo requires.
    build_dir = "_plushie_build"
    os.makedirs(os.path.join(build_dir, "src"), exist_ok=True)

    # Resolve source_path for local plushie-ext dependency
    source = os.environ.get("PLUSHIE_SOURCE_PATH") or pyproject_cfg.get("source_path")

    cargo_toml = generate_cargo_toml(
        extensions,
        binary_name=binary_name,
        build_dir=build_dir,
        source_path=source,
    )
    main_rs = generate_main_rs(extensions)

    cargo_path = os.path.join(build_dir, "Cargo.toml")
    main_path = os.path.join(build_dir, "src", "main.rs")

    with open(cargo_path, "w") as f:
        f.write(cargo_toml)
    with open(main_path, "w") as f:
        f.write(main_rs)

    print(f"generated: {cargo_path}")
    print(f"generated: {main_path}")

    # Run cargo build
    cargo_args = ["cargo", "build"]
    if release:
        cargo_args.append("--release")
    profile = "release" if release else "debug"
    verbose = getattr(args, "verbose", False)

    print(f"building{' (release)' if release else ''}...")
    result = subprocess.run(
        cargo_args,
        cwd=build_dir,
        capture_output=not verbose,
        check=False,
    )
    if result.returncode != 0:
        # Always show output on failure
        if not verbose and result.stdout:
            sys.stdout.buffer.write(result.stdout)
        if not verbose and result.stderr:
            sys.stderr.buffer.write(result.stderr)
        print("build failed", file=sys.stderr)
        raise SystemExit(result.returncode)

    if verbose and result.stdout:
        sys.stdout.buffer.write(result.stdout)

    # Install to standard download location so resolve() finds it
    from plushie.binary import download_dir, download_name

    bin_ext = ".exe" if sys.platform in ("win32", "cygwin") else ""
    built_path = os.path.join(build_dir, "target", profile, f"{binary_name}{bin_ext}")
    if os.path.isfile(built_path):
        dest_dir = download_dir()
        dest_dir.mkdir(parents=True, exist_ok=True)
        dest = dest_dir / download_name()
        shutil.copy2(built_path, str(dest))
        dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
        print(f"installed: {dest}")

        # Also install to bin_file if configured in pyproject.toml
        bin_file = pyproject_cfg.get("bin_file")
        if bin_file:
            bin_path = Path(bin_file)
            bin_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(built_path, str(bin_path))
            bin_path.chmod(bin_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
            print(f"installed: {bin_path}")
    else:
        print(f"built: {built_path}")
        print("warning: could not install -- binary not found at expected path")


def _cmd_inspect(args: argparse.Namespace) -> None:
    """Handle the ``inspect`` command."""
    from plushie.runtime import unwrap_result
    from plushie.tree import normalize

    app_class = _import_app(args.app)
    app = app_class()

    raw = app.init()
    model, _commands = unwrap_result(raw)

    raw_tree = app.view(model)
    tree = normalize(raw_tree)

    print(json.dumps(tree, indent=2, default=str))


def _cmd_script(args: argparse.Namespace) -> None:
    """Handle the ``script`` command."""
    from plushie.script import run_scripts

    files = args.files
    if not files:
        # Default: look for .plushie files in test/scripts/
        import glob

        files = sorted(glob.glob("test/scripts/**/*.plushie", recursive=True))
        if not files:
            files = sorted(glob.glob("tests/scripts/**/*.plushie", recursive=True))

    if not files:
        print("no .plushie script files found")
        raise SystemExit(1)

    success = run_scripts(files)
    if not success:
        raise SystemExit(1)


def _cmd_replay(args: argparse.Namespace) -> None:
    """Handle the ``replay`` command."""
    from plushie.script import replay_script

    replay_script(args.file)


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------


def _build_parser() -> argparse.ArgumentParser:
    """Build the CLI argument parser."""
    parser = argparse.ArgumentParser(
        prog="plushie",
        description="plushie -- native desktop GUI framework for Python",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="enable verbose logging",
    )
    subparsers = parser.add_subparsers(dest="command", help="available commands")

    # run
    run_parser = subparsers.add_parser(
        "run",
        help="run a plushie application",
    )
    run_parser.add_argument("app", help="app specifier (module:Class)")
    run_parser.add_argument(
        "--mode",
        choices=["mock", "headless"],
        default=None,
        help="renderer mode (default: windowed)",
    )
    run_parser.add_argument(
        "--json",
        action="store_true",
        help="use JSON wire format instead of msgpack",
    )
    run_parser.add_argument(
        "--watch",
        action="store_true",
        help="enable file watching for live reload",
    )
    run_parser.add_argument(
        "--daemon",
        action="store_true",
        help="keep running after all windows close",
    )

    # connect
    connect_parser = subparsers.add_parser(
        "connect",
        help="stdio transport mode (for plushie --exec)",
    )
    connect_parser.add_argument("app", help="app specifier (module:Class)")

    # download
    download_parser = subparsers.add_parser(
        "download",
        help="download precompiled binary",
    )
    download_parser.add_argument(
        "--version",
        default=None,
        help="version to download (default: pinned BINARY_VERSION)",
    )
    download_parser.add_argument(
        "--bin",
        action="store_true",
        help="download native binary (default when no target specified)",
    )
    download_parser.add_argument(
        "--wasm",
        action="store_true",
        help="download WASM renderer bundle",
    )
    download_parser.add_argument(
        "--force",
        action="store_true",
        help="re-download even if files already exist",
    )
    download_parser.add_argument(
        "--bin-file",
        default=None,
        help="override native binary destination path",
    )
    download_parser.add_argument(
        "--wasm-dir",
        default=None,
        help="override WASM output directory",
    )

    # build
    build_parser = subparsers.add_parser(
        "build",
        help="build custom binary with extensions",
    )
    build_parser.add_argument(
        "--config",
        default=None,
        help="path to extensions config JSON (default: plushie_extensions.json)",
    )
    build_parser.add_argument(
        "--name",
        default=None,
        help="output binary name (default: plushie-custom)",
    )
    build_parser.add_argument(
        "--bin",
        action="store_true",
        help="build native binary (default when no target specified)",
    )
    build_parser.add_argument(
        "--wasm",
        action="store_true",
        help="build WASM renderer from source via wasm-pack",
    )
    build_parser.add_argument(
        "--release",
        action="store_true",
        help="build with optimizations (default: debug)",
    )
    build_parser.add_argument(
        "--verbose",
        action="store_true",
        help="print full cargo output on successful builds",
    )

    # inspect
    inspect_parser = subparsers.add_parser(
        "inspect",
        help="init app, call view, normalize, print tree as JSON",
    )
    inspect_parser.add_argument("app", help="app specifier (module:Class)")

    # script
    script_parser = subparsers.add_parser(
        "script",
        help="run .plushie test scripts",
    )
    script_parser.add_argument(
        "files",
        nargs="*",
        help="script files to run (default: test/scripts/**/*.plushie)",
    )

    # replay
    replay_parser = subparsers.add_parser(
        "replay",
        help="replay a .plushie script with real windows",
    )
    replay_parser.add_argument("file", help="script file to replay")

    return parser


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main() -> None:
    """CLI entry point."""
    parser = _build_parser()
    args = parser.parse_args()

    level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format="%(levelname)s %(name)s: %(message)s",
    )

    command_map: dict[str, Any] = {
        "run": _cmd_run,
        "connect": _cmd_connect,
        "download": _cmd_download,
        "build": _cmd_build,
        "inspect": _cmd_inspect,
        "script": _cmd_script,
        "replay": _cmd_replay,
    }

    handler = command_map.get(args.command)
    if handler is None:
        parser.print_help()
        raise SystemExit(1)

    handler(args)


if __name__ == "__main__":
    main()
